自作ツールを作成した際に色々調べた知見を書きまとめておきます。
細かい解説というよりも、公式文書や参考記事などの紹介が多めです。
作成したもの
技術要素
サーバーサイド
- Django v2.2
- Graphql
- Graphene
- https://docs.graphene-python.org/
- PythonのGraphQLサーバー実装用ライブラリ
- Advanced Python Scheduler
- https://apscheduler.readthedocs.io/
- Pythonでの定期処理実行ライブラリ
フロントエンド
- TypeScript
- Vue.js (vue-cli)
- vue-class-component
- https://github.com/vuejs/vue-class-component
- Vue.jsをTypeScriptのクラスベースで実装するためのデコレータ
- vue-apollo
- https://vue-apollo.netlify.com/
- VueでApolloを使うためのライブラリ
- TypeScript対応可
- apollo
- https://www.apollographql.com/
- GraphQLクライアントライブラリ
- vuetify
- https://vuetifyjs.com/
- MaterialDesignのVueコンポーネントライブラリ
Djangoについて
Djangoのカスタムフィールド実装方法
Djangoのモデルのフィールドの種類は自前で定義できる。
詳しくは公式の以下のページを参考にdjango.db.models.Field
を継承した独自クラスを作成する。
- Writing custom model fields | Django
今回作成したアプリでは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)
をオーバーライドする- DBから取得した値を、Pythonオブジェクトにデシリアライズするメソッド
- Formと同じデシリアライズ変換でよければ
to_ptyhon
を呼び出すように実装するだけでよい - 実際には
Field
クラスにはこのメソッドの定義は無く、以下で使われるようになっている - https://docs.djangoproject.com/en/2.2/_modules/django/db/models/fields/#Field.get_db_converters
-
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を構築する方法などを紹介している。
- Managing static files (e.g. images, JavaScript, CSS) | Django documentation | Django
- Deploying static files | Django documentation | Django
上記では紹介されていないが、本番モードでアプリを稼働させるuwsgiにもstaticファイルをサーブする機能があるので、簡易なアプリならこれで十分だと思う。
実際の使い方は、以下のDocker化の紹介記事を参考に、UWSGI_STATIC_MAP
,UWSGI_STATIC_EXPIRES_URI
環境変数を指定することで実現できる。
- Here's a Production-Ready Dockerfile for Your Python/Django App | Caktus Group
# 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
環境変数そのものの説明は文書中には無い様子。
- Serving static files with uWSGI (updated to 1.9) — uWSGI 2.0 documentation
GraphQLサーバー実装
DjangoでのGraphQLサーバー実装
Pythonではgraphene ライブラリを使うことで GraphQLサーバーを実装できる。
使い方は公式ドキュメントを参考にすれば素直に実装できると思う。今回は、DjangoのModelをそのまま使うことで実装したが、DjangoのFormクラスを利用して実装することもできる。
先にGraphQLの公式ドキュメントの以下を読んでおくと実装方法がイメージしやすくなる。
- Excecution | GraphQL
ただ、ドキュメントでは細かい部分の説明が不足しているので、一部以下に補足する。
mutationのメソッド定義
Grapheneでmutationの処理を実装するときに公式文書の説明ではmutate
メソッドをインスタンスメソッドとして実装している。
- Graphene-Python
しかし、実際にはこの方法だとself
にNone
が渡されてしまう。以下のissueにあるように、どうもメソッドの定義が違う様子。
- Confusing documentation on self argument of mutate method of Mutations · Issue #951 · graphql-python/graphene
また、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 CLI 3.0 で TypeScript な Vue.js プロジェクトをつくってみる - Qiita
- vue.js + typescript = vue.ts ことはじめ - Qiita
クラスでVue.jsコンポーネントを実装するには以下のデコレータを使用する。このREADMEに目を通してどういうデコレータがあるのかを確認しておくのがよい。
- vuejs/vue-class-component: ES / TypeScript decorator for class-style Vue components.
コンポーネントを作成する上でデコレータが定義されていない要素がある場合は、@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を切り替えるのでは無く)。その方法については、以下の記事が参考になる。
- Vuetify Confirm Dialog component that can be used locally or globally
方法としては、ダイアログ用コンポーネントタグに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・キャンセルを選択するような汎用的なダイアログをつくるのは以下の記事が参考になる。
- Vue.jsで独自の確認ダイアログ(confirm)をつくる - Qiita
ここでは$root
からダイアログ起動用のメソッドを定義している。
ダイアログ用コンポーネントはメソッド起動時にnewした上で、DOM自分で追加してマウントしている。
コンポーネントのpropsData
について
この中で、コンポーネントのnew時にpropsData
を使っているが、これはvue.js公式にはUnitTest用の機能のようなのでどこまで活用していいかは迷うところ。
- API — Vue.js
また、このpropsData
の注意点として、クラス内でプロパティの初期値を指定していた場合にpropsData
より初期値側が優先されてしまう。
<script lang="ts">
import { Component, Prop } from 'vue-property-decorator';
@Component()
export default class Comp extends Vue {
@Prop() private value = "initial";
}
</script>
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の環境を構築した時に作成された以下のファイルで定義されている。
declare module '*.vue' {
import Vue from 'vue';
export default Vue;
}
コンポーネントが本来の型として扱えないことで発生する問題とその対処を以下にまとめる。
UnitTestでコンポーネントのメソッドをテストする方法
vueコンポーネントのテスト方法は以下の公式文書で説明されている。
- ガイド | Vue Test Utils
上記文書の説明では、コンポーネントがHTMLとしてどのようにレンダリングされるかをアサーションする方法だけが記載されている。しかし、それだとコンポーネントの実装と描画部分を総合した動作の確認になるため、1つのテストケースとしては範囲が広くなってしまう。コンポーネントが持つメソッド単体での個別の動作を確認するのには向いていない。
メソッド単体の動作をテストしようとすると、前述の*.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
ファイルもテストコードだと判定させる。
<!-- ファイル名を変更し、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を使っている場合の例)
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
ファイルを作成して定義を明示する必要がある。
例えば、先のグローバルなダイアログを開くメソッドをコンポーネント自身に持たせることを考える。
<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
ファイル中で使う
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
ファイルを作成して宣言する
export = Confirm;
declare class Confirm {
static open(message: string): Promise<boolean>;
}
これで*.ts
ファイル内でもコンポーネントの機能を直接扱うことができる。
Vue.js Apollo(GraphQLクライアント)の使い方
GraphQLクエリ結果からTypeScriptの型生成
型生成方法
TypeScript中でGraphQLの結果の型定義を作成する方法は以下の記事が参考になる
-
apollo-cli で GraphQL Schema から TypeScript/Flow/Scala/Swift のコードを生成する - Qiita
-
Automatically Generate TypeScript Definitions for GraphQL Queries with Apollo Codegen
上記記事を参考に今回の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
- Django + Grapheneで、GraphQLサーバーのスキーマ定義を
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での型同士の等価判定の仕様については以下を参考に:
- Type Compatibility · 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のキャッシュ機構は以下のような動きによるものだと思われる:
- Componentに保持するquery結果(
channel
)は、apollo中のキャッシュされたオブジェクトと連動している - GraphQLの
ID
カラムを持つデータがAPIで返答されたときに、キャッシュ中で同じID
をもつデータをオブジェクトの内部構造の分も含めて更新している。(GraphQLのID
カラムは全モデルを通してユニークとする特性があるため)
これを使えば、mutationの結果をコールバックなどで更新する手間が不要とも考えられるが、個人的には想定外の動作になりそうな感じがするため自前で更新処理をしたい。
この動作を止めるには、mutation実行時にキャッシュを使わないことを明示する。具体的には上記のサンプルコードでコメントアウトしている fetchPolicy: 'no-cache',
を有効化する。
もし、全体的にキャッシュを使いたくない場合は、以下を参考にデフォルト設定を行う。
- Disable cache?? · Issue #354 · Akryum/vue-apollo
vue-apolloでDjangoのAPIアクセス時のCSRFチェックへの対応方法
vue-apolloでAPI呼び出し処理をカスタマイズするときは、ApolloLink
を組み合わせることで実装する。
公式ドキュメントでの説明は以下を参照:
- Composing Links - Apollo Docs
このApolloLink
の設定をするには、plugins/vue-apollo.ts
という設定ファイルを修正する。(vue-apollo.ts
はvue-cliで作った環境に vue-apolloプラグインを追加すると生成される)
以下は一部を抜粋したものだが、crateApolloClient
メソッドに渡されるdefaultOptions
のlink
の項目がApolloLink
を設定する場所になる。
ここで指定したApolloLink
は、createApolloClient
の中で実際にHttp API呼び出しを行うHttpLink
に組み合わされるようになっている。
( option.link.concat(new HttpLink(graphql_url))
みたいになっている )
setContent
メソッドは引数のfunctionを使ってHTTPリクエストの内容に手を加えるApolloLink
オブジェクトを生成してくれるので、この中でHTTPリクエストヘッダーの内容を修正している。
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;
}
new Vue({
router,
apolloProvider: createProvider(),
render: h => h(App),
}).$mount('#app');
vue-apolloのAPIエラーハンドリング
vue-apolloでGraphQL APIのエラーハンドリングをするには、前述の ApolloLinkの機能を使って実装する。
公式ドキュメントでの説明は以下になる:
- Error handling - Apollo Docs
エラー処理用の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方式の方がよいと思う。
(抜粋)
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)
},
})