Edited at

Django + GraphQL + Vue.js + TypeScriptでアプリを作った時の知見まとめ

自作ツールを作成した際に色々調べた知見を書きまとめておきます。

細かい解説というよりも、公式文書や参考記事などの紹介が多めです。


作成したもの


技術要素

サーバーサイド

フロントエンド


Djangoについて


Djangoのカスタムフィールド実装方法

Djangoのモデルのフィールドの種類は自前で定義できる。

詳しくは公式の以下のページを参考にdjango.db.models.Fieldを継承した独自クラスを作成する。

今回作成したアプリではJSON型のフィールドを使いたかったが、モデルのフィールドとしては無いようなので自作した。(Form機能としてはJSON型のフィールドクラスがある)

実装例は、https://github.com/namutaka/hottip/blob/master/hottip/fields.py にて。

実装のポイントとしては以下の通り:

カスタムのFieldクラスの実装はPythonオブジェクトをシリアライズ・デシリアライズする処理を書くのが主な作業。

この時のシリアライズには、DBで格納する為のシリアライズと、WebページのFormで使用する為のシリアライズとの2種類あり、それぞれの対応を行う必要がある。

Fieldクラスに、get_db_prep_value()get_prep_valueのように_db_が着くかどうかの名前違いのメソッドがあるのはそのため。

DBとFormで同じシリアライズでよければ、以下で事足りるようす。



  • from_db_value(self, value, expression, connection) をオーバーライドする




  • to_python(self, value)をオーバーライドする


    • Formの入力値を、Pythonオブジェクトにデシリアライズするメソッド

    • このメソッドのvalueにはデシリアライズ済みの値がくることがある




  • get_prep_value(self, value)をオーバーライドする


    • Pythonオブジェクトを、Form向けにシリアライズをするメソッド

    • このメソッドのvalueにはシリアライズ済みの値がくることがある。Formでバリデーションエラーした場合にFormの入力値が渡されるときなど




  • PythonオブジェクトからDB向けのシリアライズは、get_db_prep_valueメソッドにて行うが、Fieldクラスのこのメソッド自体がget_prep_valueを使うようになっているのでそれに任せておけばいい




staticファイルをDjango自体から提供 & DjangoでのDockerイメージ生成方法

DjangoではstaticファイルをDjango自体からサーブする機能があるが、これは開発モードでしか機能しないようになっている。公式ドキュメントではstaticファイルをサーブするのに、nginxやApacheを構築する方法などを紹介している。

上記では紹介されていないが、本番モードでアプリを稼働させるuwsgiにもstaticファイルをサーブする機能があるので、簡易なアプリならこれで十分だと思う。


実際の使い方は、以下のDocker化の紹介記事を参考に、UWSGI_STATIC_MAP,UWSGI_STATIC_EXPIRES_URI環境変数を指定することで実現できる。


Dockerfile

# uWSGI configuration

# '/app/static/'フォルダを http://ホスト/static/ からアクセスできるようにする
ENV \
UWSGI_STATIC_MAP="/static/=/app/static/" \
UWSGI_STATIC_EXPIRES_URI="/static/.*\.[a-f0-9]{12,}\.(css|js|png|jpg|jpeg|gif|ico|woff|ttf|otf|svg|scss|map|txt) 315360000"

uWSGIの公式ドキュメントでこの設定の説明は以下にあるが、UWSGI_STATIC_MAP環境変数そのものの説明は文書中には無い様子。


GraphQLサーバー実装


DjangoでのGraphQLサーバー実装

Pythonではgraphene ライブラリを使うことで GraphQLサーバーを実装できる。

使い方は公式ドキュメントを参考にすれば素直に実装できると思う。今回は、DjangoのModelをそのまま使うことで実装したが、DjangoのFormクラスを利用して実装することもできる。

先にGraphQLの公式ドキュメントの以下を読んでおくと実装方法がイメージしやすくなる。

ただ、ドキュメントでは細かい部分の説明が不足しているので、一部以下に補足する。


mutationのメソッド定義

Grapheneでmutationの処理を実装するときに公式文書の説明ではmutateメソッドをインスタンスメソッドとして実装している。

しかし、実際にはこの方法だとselfNoneが渡されてしまう。以下のissueにあるように、どうもメソッドの定義が違う様子。

また、mutate処理中でMutationの定義クラスをそのものを使いたい場合には、@classmethodで定義すればクラスオブジェクトを取得できる。

Grapheneでのこのあたりの仕様はふわっとしている印象。


GrapheneでのIDカラムの定義

GraphQLでは、モデルのid(ID型の値)は全種類のモデルを含めた中でユニークな値にするのがよいとされている( https://graphql.org/learn/caching/#globally-unique-ids )。

Grapheneではモデルのidカラム(基本は数値)を自動でGraphQLのID型(中身は文字列)とするため、もしクライアント側で元のid値が必要な場合は独自にフィールドを定義する必要がある。

class ChannelNode(DjangoObjectType):

class Meta:
model = Channel

# DjangoのModelのidカラム
raw_id = graphene.Int()

@staticmethod
def resolve_raw_id(channel, info):
""" channel: Djangoのモデルインスタンス """
return channel.id

また、mutationのときなどでGraphQLのID型の値を Djangoモデルのidカラム値に変換する必要があるときは、graphql_relay.node.node.from_global_id(String)メソッドを使う。ただこのメソッドは、公式ドキュメントでは説明がなくStackOverflowでコメントがあった程度のものなので、パッケージの深さから考えてライブラリ利用側で使うことを想定していない機能かもしれない。

from graphql_relay.node.node import from_global_id

class UpdateHogeMutation(graphene.Mutation):

@classmethod
def mutate(cls, root, info, **fields):
"""Mutationクエリの処理
cls: このクラス自体
root: このmutation処理の親コンテキストのノード
info: 処理中のコンテキスト
fields: mutationクエリに渡されたフィールド群
"""

# ID型の値を モデルのidカラム値に変換
id = fields.pop('id')
obj_id = from_global_id(id)[1] # [ 型名, id値 ]というタプルが返されるので、2要素目を取得

# idカラム値を使ってDB操作
obj = Hoge.objects.get(pk=obj_id)
for key, value in model_fields.items():
setattr(obj, key, value)
obj.full_clean()
obj.save(update_fields=model_fields.keys())

# クエリの結果を返答
return cls(**{cls.field_name: obj})


GraphQLでのmutationのvalidationエラー対応

GraphQLでmutation時のバリデーションエラーをどう返すのがいいのか分からなかったので、以下のようにした。


  • mutationクエリのレスポンスにerrorsというフィールドでカラムごとのバリデーションエラーを返答

  • バリデーション成功時は、更新されたモデルオブジェクトを返すと共に、errorsは空を返答

  • バリデーションエラー時は、モデルオブジェクトは返さず、errorsのみを返答

この方法にした理由:


  • バリデーションエラーはmutation操作の正当な返答のパターンの一つとして考えて、GraphQLとしてのエラー返答にはしない方がいいと判断

  • GraphQLのエラー返答は、HTTPでの500エラー系相当とイメージ

  • 自前でバリデーションエラーの構造を定義する方が実装しやすいとも思う

# mutation クエリ例

mutation createHoge($name: String!, $description: String) {
createHoge(name: $name, description: $description) {
hoge {
id
name
description
createdAt
updatedAt
}
errors {
field
messages
}
}
}

class ErrorType(graphene.ObjectType):

""" フィールドごとのエラー情報を持つモデル """
field = graphene.String(required=True)
messages = graphene.List(graphene.NonNull(graphene.String), required=True)

@staticmethod
def list_from_dict(error_dict):
"""DjangoのValidationErrorからエラーのリストを生成する"""
return [ErrorType(field=key, messages=value)
for key, value in error_dict.items()]

class CreateHogeMutation(graphene.Mutation):
""" Hoge を新規作成するmutationクエリ定義 """

hoge = graphene.Field(Hoge)
errors = graphene.List(graphene.NonNull(ErrorType), required=True)

@classmethod
def mutate(cls, root, info, **fields):
newHoge = cls.model(**fields)

try:
# バリデーションしてから保存する
newHoge.full_clean()
newHoge.save()

return cls(hoge=newHoge, errors=[])

except ValidationError as e:
# バリデーションエラーの情報を返答
return cls(
errors=ErrorType.list_from_dict(e.message_dict))


Vue.js + TypeScript(クラススタイル)


Vue.js を TypeScript(クラススタイル) で実装する方法

基本的にはvue-cliでアプリの雛形を生成するときに、TypeScriptの利用・クラススタイル使用 のオプションを選択すればよい。

詳しい説明は以下の記事参照。

クラスでVue.jsコンポーネントを実装するには以下のデコレータを使用する。このREADMEに目を通してどういうデコレータがあるのかを確認しておくのがよい。

コンポーネントを作成する上でデコレータが定義されていない要素がある場合は、@Componentの引数に通常のVueのように記載すればよい。

@Component({

// デコレータのサポート外の要素
apollo: {
my_data: GRAPHQL_QUERY
}
})
export default class MyApp extends Vue {
}

これでTypeScriptの型定義を利用して実装できるが、コンポーネントのパラメータを跨いだデータのやりとり部分には流石に型チェックがかからない。

<template>

<div>
<!-- MyCompの my-valueプロパティの型と、myValue値の型が違うかも -->
<MyComp my-value="myValue" />
</div>
</template>


Vue.js + TypeScriptでのダイアログフォーム実装方法

初期化をしながらダイアログを開くようなことをするために、メソッドを呼び出す形でダイアログを開くようにしたい(dialogプロパティのtrue/falseを切り替えるのでは無く)。その方法については、以下の記事が参考になる。

方法としては、ダイアログ用コンポーネントタグにref属性を指定し、this.$refs.hogeDialogとしてコンポーネントインスタンスにアクセスしてダイアログ起動用のメソッドを実行することができる。

ただし、これをTypeScriptでやろうとするとコンポーネントの型を明示してあげないと、TypeScriptの型チェックで怒られる。そこで、以下のようにthis.$refs.hogeDialogの型を明示して対応する。

<template>

<div>
<!--
refを指定してコンポーネントインスタンスにアクセスできるように。
フォームの決定操作のコールバックを指定する。
-->

<TipForm
ref="tipForm"
@change-tip="changeTip" />

<div>
<button @on="add">ADD</button>
<button @on="edit(tip)">EDIT</button>
</div>
</div>
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import TipForm from '@/components/TipForm.vue';
import { Tip } from '@/types/models';

@Component({
components: {
TipForm,
},
})
export default class TipView extends Vue {
// this.$refs.tipForm の型を明示
$refs!: {
tipForm: TipForm;
};

@Prop() private tip!: Tip;

add() {
// tipFormが TipForm型として認識され、openメソッドを使える
this.$refs.tipForm.open();
}

edit(tip: Tip) {
this.$refs.tipForm.open(tip); // ダイアログの初期値を渡せる
}

changeTip(newTip: Tip) {
// 新しいtipを使って更新
this.tip = {...this.tip, ...newTip};
}
}
</script>

ダイアログ側の実装は以下の感じ

```



<button @on="close">OK</button>



ちなみに、VueでformタグなどのDOMをrefで使用するときも同様に`HTMLFormElement`クラスであることを明示する必要がある。

```typescript
@Component({})
export default class ChannelForm extends Vue {
$refs!: {
form: HTMLFormElement;
};
save() {
if (this.$refs.form.validate()) {
// save処理など
}
}
}


グローバルなダイアログ実装方法

OK・キャンセルを選択するような汎用的なダイアログをつくるのは以下の記事が参考になる。

ここでは$rootからダイアログ起動用のメソッドを定義している。

ダイアログ用コンポーネントはメソッド起動時にnewした上で、DOM自分で追加してマウントしている。


コンポーネントのpropsDataについて

この中で、コンポーネントのnew時にpropsDataを使っているが、これはvue.js公式にはUnitTest用の機能のようなのでどこまで活用していいかは迷うところ。

また、このpropsDataの注意点として、クラス内でプロパティの初期値を指定していた場合にpropsDataより初期値側が優先されてしまう。


Comp.vue

<script lang="ts">

import { Component, Prop } from 'vue-property-decorator';

@Component()
export default class Comp extends Vue {
@Prop() private value = "initial";
}
</script>



some.ts

funtion somefunc() {

let comp = new Comp({
propsData: {
value: "new value";
}
});

// このとき`comp`の中の`value`の値は`"initial"`になってしまう
}



ちなみに

上記の方法だと$rootに追加するメソッドの型をConfirmMixin.tsファイルの中で以下のように定義しているが、これをsrc/types.d.tsに記述してしまうと問題が起きる。

そもそも追加する$confirmが未定義判定となる上、vue-apollo内でVue.$apolloを型定義してあるが、これすらも未定義扱いになってしまう。

declare module 'vue/types/vue' {

interface Vue {
$confirm(message: string): Promise<boolean>;
}
}


*.vueファイルのコンポーネントの型を正しく判別させる

*.vueファイルのVueコンポーネントは、importされるファイルによって違うクラスに判定されるようである。

別の*.vueファイルでimportした場合は、実際に作成したコンポーネントのクラスとして正しく扱われる。一方、*.tsファイルでimportした場合は、Vue(VueConstructorと同義)クラスとして扱われる。

この*.tsファイル内での*.vueファイルのクラスは、vue-cliでvue.js + TypeScriptの環境を構築した時に作成された以下のファイルで定義されている。


src/shims-vue.d.ts

declare module '*.vue' {

import Vue from 'vue';
export default Vue;
}

コンポーネントが本来の型として扱えないことで発生する問題とその対処を以下にまとめる。


UnitTestでコンポーネントのメソッドをテストする方法

vueコンポーネントのテスト方法は以下の公式文書で説明されている。

上記文書の説明では、コンポーネントがHTMLとしてどのようにレンダリングされるかをアサーションする方法だけが記載されている。しかし、それだとコンポーネントの実装と描画部分を総合した動作の確認になるため、1つのテストケースとしては範囲が広くなってしまう。コンポーネントが持つメソッド単体での個別の動作を確認するのには向いていない。

メソッド単体の動作をテストしようとすると、前述の*.tsファイルでのクラスの扱いの問題が出てくる。


test/components/MyComp.spec.ts

import MyComp from '@/components/MyComp.vue';

describe('MyComp', () => {
it('myFunc', () => {
let myComp = new MyComp();

expect(myComp.myFunc('10')).toBe(true);
// => [Error] MyCompが、`VueConstructor`と扱われるので、`myFunc`が未定義扱いになる
});
});


これに対処するには、テストを実装するファイルを.vue拡張子にする。

具体的には、ファイルを〜〜.spec.vueにリネームするとともに、テスト実行設定ファイルにて*.vueファイルもテストコードだと判定させる。


/components/MyComp.spec.vue

<!-- ファイル名を変更し、scriptタグで囲う -->

<script lang="ts">
import MyComp from '@/components/MyComp.vue';

describe('MyComp', () => {
it('myFunc', () => {
let myComp = new MyComp();

expect(myComp.myFunc('10')).toBe(true);
// => [Error] MyCompが、`VueConstructor`型と扱われるので、`myFunc`が見定義扱いになる
});
});
</script>


テストランナの設定を変更する(以下は、Jestを使っている場合の例)


jest.config.js

 module.exports = {

(略)

testMatch: [
// 拡張子の *.spec.vue も対象に加える
- "<rootDir>/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"
+ "<rootDir>/tests/unit/**/*.spec.(js|jsx|ts|tsx|vue)|**/__tests__/*.(js|jsx|ts|tsx|vue)"
],

(略)
};


上記の対応でテストコードが*.vueファイルになっていてもvue-cliでのテスト実行(vue-cli-service test:unitコマンド)でテストが実行できる


*.tsファイル中でコンポーネントを使いたい場合

先のグローバルなダイアログの実装例のように、Vue全体の初期化などで*.tsファイルでコンポーネントを使いたいことはある。そういった時に、そのコンポーネントのメソッドを使おうとすると、前述の通りにメソッドが未定義扱いになってしまう。

その場合は、自前でコンポーネントに対する*.d.tsファイルを作成して定義を明示する必要がある。

例えば、先のグローバルなダイアログを開くメソッドをコンポーネント自身に持たせることを考える。


/path/to/Confirm.vue

<script lang="ts">

(略)

@Component({})
export default class Confirm extends Vue {
(略)

// Confirmダイアログを開く
static open(message: string): Promise<boolean> {
return new Promise((resolve) => {
new Confirm({
propsData: {
message,
success: () => resolve(true),
failure: () => resolve(false),
},
});
});
}

};
</script>


この Confirm.openメソッドをConfirmMixin.tsファイル中で使う


ConfirmMixin.ts

import Confirm from '/path/to/Confirm.vue';

export default class {
static install(Vue: VueConstructor) {
Vue.mixin({
methods: {
// `Confirm.d.ts`ファイルが無いと、
// `Confirm`が`VueConstructor`型と扱われて`open`が未定義扱いになる
$confirm: Confirm.open
}
});
}
}


その上で、Confirm.openが存在することを以下のConfirm.d.tsファイルを作成して宣言する


/path/to/Confirm.d.ts

export = Confirm;

declare class Confirm {
static open(message: string): Promise<boolean>;
}


これで*.tsファイル内でもコンポーネントの機能を直接扱うことができる。


Vue.js Apollo(GraphQLクライアント)の使い方


GraphQLクエリ結果からTypeScriptの型生成


型生成方法

TypeScript中でGraphQLの結果の型定義を作成する方法は以下の記事が参考になる

上記記事を参考に今回のDjango + Graphene + Vue.js環境での方法をまとめると以下のようになる。

<前提>


  • DjangoでGraphQLサーバーを実装済み

  • TypeScript側でアプリ中に利用するGraphQLクエリをgraphql-tagを使って実装済み

<作業>

Step.1


  • Django環境で以下コマンド実行

# Grapheneが定義する graphql_schema コマンドを実行

python manage.py graphql_schema \
--schema myapp.schema.schema \
--out schema.json

Step.2


  • TypeScript環境で以下コマンド実行

# apolloモジュールが定義する apollo コマンド実行

yarn run apollo codegen:generate \
--localSchemaFile=../schema.json \
--target=typescript \
--includes='src/graphql/*.ts' \
--tagName=gql \
--no-addTypename \
types


  • 先に作ったschema.jsonとGraphQLが定義されたtsファイル(src/graphql/*.ts)を基にして、srcフォルダ下のtypesフォルダに型定義ファイル一式を生成する


  • --no-addTypename指定で、型内に__typenameフィールドの生成を抑止している

これで、src/types/{クエリ名}.tsというファイルが生成され以下のような型が定義される。

// src/types/allChannels.ts

// `query allChannels { 〜〜 }` というクエリから生成された

// 一部省略

export interface allChannels_allChannels {
// --no-addTypenameオプションにより以下のような型名称の定義を止めている
// __typename: "ChannelNodeConnection";

edges: (allChannels_allChannels_edges | null)[];
pageInfo: allChannels_allChannels_pageInfo;
}

export interface allChannels {
allChannels: allChannels_allChannels | null;
}

ここで生成する型定義は、「クエリ結果」の型であり、「GraphQLサーバー側のモデル定義の型」ではない。GraphQLが、クエリの記述によって取得するオブジェクトの構造を規定する仕組みであるため、必ずクエリ結果の構造 = モデル構造とはならないためだと思う。

つまり、フロントエンド内でサーバー側のモデルと同等のモデル定義が必要な場合は、別途自前で定義する必要がある。このとき、基本的にはモデル型とクエリ結果の型が同等となるようにするのが良いと思う。

なお、この時の型の等価性判定の邪魔になるため、__typenameを生成しないようにしている。

TypeScriptでの型同士の等価判定の仕様については以下を参考に:

前述のコマンドはサーバー側やクエリの修正を行うたびに実行するので、以下のようにpipenvのサブコマンドとして定義しておくと良い。

# Pipfile

[scripts]
# `pipenv run update_gql`で実行
update_gql = "bash -xc 'python manage.py graphql_schema --schema myapp.schema.schema --out schema.json && (cd frontend; yarn run apollo codegen:generate --localSchemaFile=../schema.json --target=typescript --includes=src/graphql/*.ts --tagName=gql --no-addTypename types)'"


型の使い方

型の使い方は以下のとおり。

Componentが保持する情報をGraphQLから取得する場合:

import { Component, Vue } from 'vue-property-decorator';

// GraphQLのqueryクエリを定義した定数
import { CHANNEL } from '@/graphql/queries';

// コマンドで生成された型定義ファイルからをimport
import { channel_channel } from '@/graphql/types/channel';

@Component({
apollo: {
channel: {
// GraphQLのクエリを指定(定数にて定義しておいた)
query: CHANNEL,
},
},
})
export default class ChannelPage extends Vue {
// コマンドで生成したGraphQLのクエリ結果の型を利用
private channel!: channel_channel;
}

Mutation動作での型利用:

import { Component, Emit, Vue } from 'vue-property-decorator';

import { QueryResult } from 'vue-apollo/types/vue-apollo';

// GraphQLのmutationクエリを定義した定数
import { CREATE_CHANNEL } from '@/graphql/queries';

// コマンドで生成したクエリ結果の型
import { createChannel } from '@/graphql/types/createChannel';

@Component({})
export default class ChannelForm extends Vue {
save() {
// mutation処理のPromiseの型は以下のようになる
let mutation: Promise<QueryResult<createChannel>>;

// mutateメソッドに genericsの型を明示すると結果の型を指定できる
mutation = this.$apollo.mutate<createChannel>({
mutation: CREATE_CHANNEL,
variables: {
...this.editedChannel,
},
fetchPolicy: 'no-cache',
});

mutation
// createChannel型の内容を展開して取得しても使える
// resultにも型が紐付いている
.then(({ data: { result } }) => {
if (!result) { throw 'result is null'; }
return result;
})
.then(({ channel, errors }) => {
if (channel) {
this.close();
this.changeChannel(channel);
} else { // バリデーションエラー
this.valid = false;
this.errors = errors;
}
});
}
}


apolloのキャッシュ機構への対処

apolloは内部にキャッシュ機構を持っていて、GraphQLの返答結果を保持している。しかし、これがちょっと強力すぎて戸惑う面があるので、うまく使うには注意が必要。

import { Component, Vue } from 'vue-property-decorator';

import gql from 'graphql-tag';

@Component({
apollo: {
channel: {
query: gql`
query channel() {
channel() {
id
name

tips {
id
title
}
}
}
`,
},
},
})
export default class TestPage extends Vue {
private channel!: any;

updateTip() {
// リスト中のTipを更新
let tip = this.channel.tips[1];
let updatedTip = JSON.parse(JSON.stringify(tip)); // deepcopy
updatedTip.title = "title" + new Date();

// mutationで更新
let mutation = this.$apollo.mutate({
mutation: gql`
mutation updateTip(
$id: ID!
$title: String
) {
updateTip(id: $id, title: $title) {
tip {
id
title
}
}
}
`
,
variables: updatedTip,
// fetchPolicy: 'no-cache',
});
}
}

例えば、上記のようにqueryで取得したchannel中のtipsの1つをmutationで更新する処理があるとする。このとき、GraphQL APIからmutationクエリの結果が返答された時点で、channel.tips[1]の内容が自動で更新される。

これは、apolloのキャッシュ機構は以下のような動きによるものだと思われる:


  1. Componentに保持するquery結果(channel)は、apollo中のキャッシュされたオブジェクトと連動している

  2. GraphQLのIDカラムを持つデータがAPIで返答されたときに、キャッシュ中で同じIDをもつデータをオブジェクトの内部構造の分も含めて更新している。(GraphQLのIDカラムは全モデルを通してユニークとする特性があるため)

これを使えば、mutationの結果をコールバックなどで更新する手間が不要とも考えられるが、個人的には想定外の動作になりそうな感じがするため自前で更新処理をしたい。

この動作を止めるには、mutation実行時にキャッシュを使わないことを明示する。具体的には上記のサンプルコードでコメントアウトしている fetchPolicy: 'no-cache',を有効化する。

もし、全体的にキャッシュを使いたくない場合は、以下を参考にデフォルト設定を行う。


vue-apolloでDjangoのAPIアクセス時のCSRFチェックへの対応方法

vue-apolloでAPI呼び出し処理をカスタマイズするときは、ApolloLinkを組み合わせることで実装する。

公式ドキュメントでの説明は以下を参照:

このApolloLinkの設定をするには、plugins/vue-apollo.tsという設定ファイルを修正する。(vue-apollo.tsはvue-cliで作った環境に vue-apolloプラグインを追加すると生成される)

以下は一部を抜粋したものだが、crateApolloClientメソッドに渡されるdefaultOptionslinkの項目がApolloLinkを設定する場所になる。

ここで指定したApolloLinkは、createApolloClientの中で実際にHttp API呼び出しを行うHttpLinkに組み合わされるようになっている。

( option.link.concat(new HttpLink(graphql_url)) みたいになっている )

setContentメソッドは引数のfunctionを使ってHTTPリクエストの内容に手を加えるApolloLinkオブジェクトを生成してくれるので、この中でHTTPリクエストヘッダーの内容を修正している。


plugins/vue-apollo.ts

import { setContext } from 'apollo-link-context';

import { createApolloClient } from 'vue-cli-plugin-apollo/graphql-client';
import Cookies from 'js-cookie'; // yarn add js-cookie

const defaultOptions = {
link:
// httpヘッダーを追加する
setContext((_, { headers }) => {
return {
headers: {
...headers,
// Djangoの仕様にあわせて cookieのCSRFトークンをHTTPヘッダーに追加
'X-CSRFToken': Cookies.get('csrftoken'),

// 以下は、DjangoにHTTPリクエストがAjaxに寄るものであることを指定するため
// CSRFとは関係ないが、Django側の `request.is_ajax()`の判定のため
'X-Requested-With': 'XMLHttpRequest',
},
};
})

(略)
}

export function createProvider(options = {}) {
const { apolloClient } = createApolloClient({
...defaultOptions,
...options,
});

const apolloProvider = new VueApollo({
defaultClient: apolloClient,
});

return apolloProvider;
}



main.ts

new Vue({

router,
apolloProvider: createProvider(),
render: h => h(App),
}).$mount('#app');


vue-apolloのAPIエラーハンドリング

vue-apolloでGraphQL APIのエラーハンドリングをするには、前述の ApolloLinkの機能を使って実装する。

公式ドキュメントでの説明は以下になる:

エラー処理用のApolloLinkを生成するためのonErrorメソッドが用意されているので、これを使って実装する。生成したエラー用のApolloLinkオブジェクトを、先のcreateApolloClientメソッドのlinkオプションに指定する。

先のCSRF対応の処理をあわせると以下のようになる。


// error handling
const errorLink = onError(({ graphQLErrors, networkError }) => {
// API返答はあるが、中身がGraphQLとしてのエラー返答の場合。
if (graphQLErrors)
graphQLErrors.map(({ message, locations, path }) =>
console.log(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`,
),
);

// HTTP APIそのもののエラー(statusが200系でない、など)
if (networkError) {
// networkErrorは、Unionクラスで statusCode があることが確定しないため、型チェックを除外
// @ts-ignore
const statusCode = networkError.statusCode || -1;
console.log(`[Network error]: status=${statusCode} ${networkError}`);

// 非ログイン時の対応例
// 認証の場合に403返答を返すこととし、その場合にログイン画面に強制遷移させる
if (statusCode == 403) {
const url = window.location.pathname + window.location.search;
router.push({ name: 'login', query: { next: url } });
}
}
});

const defaultOptions = {
// エラー対応のlinkと、CSRF対応のsetContextで生成されるlinkを結合する
link: errorLink.concat(
setContext((_, { headers }) => {
return {
headers: {
...headers,
'X-CSRFToken': Cookies.get('csrftoken'),
'X-Requested-With': 'XMLHttpRequest',
},
};
})
),
}

GraphQLのエラー返答 (https://graphql.org/learn/serving-over-http/#response) の場合と、HTTPとしてのエラーがあるので、それぞれに応じた処理を記述する。

なお、vue-cliのvue-apolloプラグインで初期生成されるplugins/vue-apollo.tsファイルにはエラーハンドリング用の処理も生成される。ただし、このerrorHandlerは 「VueApollo」としてのエラーハンドリングらしく、コンポーネントに割り当てたデータ取得queryクエリ(@Component({apollo: hoge: { query: gql〜〜} }))の実行時エラーにのみ対応している。そのため、自前でmutationクエリを実行する場合(コンポーネント内でのthis.$apollo.mutate({ mutation: gql〜〜})のやつ)には対応していない。そのため、エラーハンドリングはonErrorでのApolloLink方式の方がよいと思う。


src/vue-apollo.js

(抜粋)

const apolloProvider = new VueApollo({
defaultClient: apolloClient,
defaultOptions: {
$query: {
// fetchPolicy: 'cache-and-network',
},
},
errorHandler (error) {
// eslint-disable-next-line no-console
console.log('%cError', 'background: red; color: white; padding: 2px 4px; border-radius: 3px; font-weight: bold;', error.message)
},
})

(https://github.com/Akryum/vue-cli-plugin-apollo/blob/master/generator/templates/vue-apollo/default/src/vue-apollo.js#L71)