現状、コードの説明が足りないので、後日追記します。
この記事で行うこと
vue-apolloに付属するデモチャットアプリ「ApolloChat」のGraphQLサーバーを、PostGraphileで実装して置き換え、PostgreSQLにデータ永続化するようにします。
以後、vue-apolloに付属するApolloChatを「オリジナルApolloChat」、PostGraphileに置き換えるApolloChatを「PostGraphile版ApolloChat」と呼びます。
PostGraphile版ApolloChatのソースコード
https://github.com/kanedaq/vue-apollo から入手できます。動かすには、以下を実行してWebサーバーを立ち上げ、ブラウザから「 http://localhost:8080/demo/ 」にアクセスします。
git clone https://github.com/kanedaq/vue-apollo
cd vue-apollo/tests/demo/
yarn install
yarn serve
実装の手抜き(デモにありがちな)
- httpsで通信すべきところもhttpで通信しています。
- 本記事ではJWTをローカルストレージに保存していますが、本番環境ではなさらないようお願いします。HTML5のLocal Storageを使ってはいけない(翻訳) (2019-10-10追記)
- ログアウト処理はサーバー側は何もせずにtrueを返すだけにし、フロント側でブラウザのローカルストレージのJWTを削除するに留めました。きっちり実装したい場合は、以下のサイト等をご参照ください。
- ログイン中に"jwt expired"のために動作が怪しくなったら、ログアウトボタンを押して強制的にJWTを削除してください。
- ログイン画面で"jwt expired"のためにログインできなくなったら、ブラウザのローカルストレージに残っているJWT(キー:postgraphile-demo-token)を手動で削除してください。
- その他、PostGraphileへの移植に際して、実装の抜け漏れがあるかと思います。
- 私(C++おじさん)はまだJavaScriptもVue.jsも経験が浅いので、変な箇所がありましたら優しくご指摘ください。
参考ページ(感謝します)
GraphiQL で Header が指定できない対応策
Vue:Invalid Host Header
Vue.jsプロジェクトをサブディレクトリで配信するときの注意点
Vue CLI3のDevServerでWebSocketを使う
nginx(1.3.13)でWebSocketのプロキシを試した
Node.jsでlog4jsを使ってログを出力する
util.inspect 便利
vue-apolloを入手し、オリジナルApolloChatを動かしてみる
GraphQLバックエンドをPostGraphileに移植する前に、まずオリジナルApolloChatを動かしてみます。
作業は ~/work で行うことにします(私はMacを使用しました)。
vue-apolloを入手します。
cd ~/work
git clone https://github.com/Akryum/vue-apollo
cd vue-apollo
ここで「git log」を実行すると、以下のように表示されました。
$ git log
commit 9419ffd03d5c0942ac5954572920ffa2281f1851 (HEAD -> master, origin/master, origin/HEAD)
Author: Darryl Hein <dhein@xmmedia.com>
Date: Tue May 28 11:38:41 2019 -0600
docs: Minor spelling correction (#635)
(後略)
オリジナルApolloChatのディレクトリに移動し、必要なパッケージをインストールします。
cd ~/work/vue-apollo/tests/demo
yarn install
早速オリジナルApolloChatを動かしてみます。起動方法を調べるには、
more package.json
以下のように表示されました。
{
"name": "demo",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"apollo:dev": "vue-cli-service apollo:watch",
"apollo:run": "vue-cli-service apollo:run",
"test:e2e:dev": "start-server-and-test apollo:dev http://localhost:4000/.well-known/apollo/server-health test:e2e:dev:client",
"test:e2e:dev:client": "vue-cli-service test:e2e --mode development",
"test:e2e": "start-server-and-test apollo:run http://localhost:4000/.well-known/apollo/server-health test:e2e:client",
"test:e2e:client": "vue-cli-service test:e2e --mode production --headless",
"test": "yarn run lint --no-fix && yarn run test:e2e"
},
オリジナルApolloChatの起動コマンドは、
・GraphQLサーバー:yarn apollo:dev または yarn apollo:run
・Webサーバー(Vue.jsアプリ配信):yarn serve
で良さそうです。
まず「yarn apollo:dev」を実行してGraphQLサーバーを起動すると、以下のように表示されました。
warning ../../../package.json: No license field
$ vue-cli-service apollo:watch
warning ../../../package.json: No license field
$ vue-cli-service apollo:run --delay
Using default PubSub implementation for subscriptions.
You should provide a different implementation in production (for example with Redis) by exporting it in 'apollo-server/pubsub.js'.
✔️ GraphQL Server is running on http://localhost:4000/graphql
✔️ Type rs to restart the server
表示に従い、ブラウザで「 http://localhost:4000/graphql 」にアクセスすると、公式GraphiQLと似た画面が立ち上がります。本記事では省略しますが、「 ~/work/vue-apollo/tests/demo/src/graphql/ 」配下のgqlファイルに書かれているGraphQL問い合わせを試すことができます。画面の左下で「HTTP HEADERS」を入力できるので、ログイン処理で返ってきたJWTをHEADERSに設定すれば、ログインが必要な問い合わせを試すこともできますね。
引き続き、GraphQLサーバーを立ち上げたままの状態で、別コンソールでWebサーバーも起動します。
cd ~/work/vue-apollo/tests/demo
yarn serve
フロントエンドがコンパイルされた後、以下のように表示されました。
DONE Compiled successfully in 7780ms 17:51:01
App running at:
- Local: http://localhost:8080/
- Network: unavailable
Note that the development build is not optimized.
To create a production build, run yarn build.
表示に従い、ブラウザで「 http://localhost:8080/ 」にアクセスすると、オリジナルApolloChatが立ち上がります。
まずログイン画面が現れます。
アカウント作成画面。
アカウントを作成し、ログインした直後のウェルカム画面。
generalチャンネルに入って、「こんにちは世界」と送信しようとしている画面。
「こんにちは世界」と送信した直後の画面。
いろいろ動かしてみると、オリジナルApolloChatのGraphQLサーバーはデータを永続化せずにメモリ上に保存している様子で、GraphQLサーバーを落とすと作成したアカウントやチャット内容が失われることがわかります。
バックエンド実装
それでは、オリジナルApolloChatのGraphQLサーバーをPostGraphileに移植する作業を開始します。データはPostgreSQLに永続化されます。
環境構築&バックエンド実装(その1):PostgreSQL関連まで
以前書いた記事「PostgreSQLを操作するAPIを提供する、PostgRESTとPostGraphileを両方試してみた」の「環境構築その1」と全く同じなので、記事は割愛します。
この環境設定では、Laradockのworkspaceの「/var/www」がホスト側の「~/work」にマウントされます。
環境構築&バックエンド実装(その2):DB作成
ER図
オリジナルApolloChatを動かしたり、~/work/vue-apollo/tests/demo/apollo-server/schema.graphql を見ながら、ER図を描いてみました。
4つのテーブルのうち、ログインユーザーに見せたい以下の3つは、PostgreSQLでapollo_demoスキーマに格納することにします。
- channels
- messages
- users
ユーザに見せたくない以下のテーブルは、PostgreSQLでapollo_demo_privateスキーマに格納することにします。
- user_accounts
PostgreSQLの機能であるスキーマは、名前空間のようなもので、見せたいオブジェクトと見せたくないオブジェクトを分けるセキュリティー用途に使用することができます。
実装作業
混乱を避けるため、オリジナルのGraphQLサーバーは削除し、代わりにこれから開発するバックエンドのファイルの置き場所を作成します。
cd ~/work/vue-apollo/tests/demo/
rm -rf apollo-server
mkdir postgraphile-server
Laradockのworkspaceに入リます。
cd ~/work/laradock
docker-compose exec --user=laradock workspace bash
更にpsqlに入リます。defaultユーザのパスワード: secret
psql -h postgres -U default
PostGraphile版ApolloChat用DBを作成し、接続できることを確認します。
DB名をdemodbとしましたが、名前は何でも良いです。
create database demodb;
\connect demodb
「 \connect demodb 」し忘れると、別のデータベース内にスキーマを作成することになりますのでご注意ください。
引き続き以下のSQLを実行して、スキーマとデータを作成します(内容は後ほどご説明します)。
このSQLは「 ~/work/vue-apollo/tests/demo/postgraphile-server/schema_and_data.sql 」に保存しておきます。
なお、JWTの期限を発行後60分にしてあります。変更したい場合は、「60 minutes」の部分を書き換えてください。
begin;
create role apollo_demo_postgraphile login password 'xyz';
create role apollo_demo_anonymous;
grant apollo_demo_anonymous to apollo_demo_postgraphile;
create role apollo_demo_login_user;
grant apollo_demo_login_user to apollo_demo_postgraphile;
create schema apollo_demo;
create schema apollo_demo_private;
create table apollo_demo.users (
id integer primary key generated by default as identity,
nickname text not null,
email text not null unique check (email ~* '^.+@.+\..+$')
);
comment on table apollo_demo.users is 'A user of the chat.';
comment on column apollo_demo.users.id is 'The primary unique identifier for the user.';
comment on column apollo_demo.users.nickname is 'The user’s nickname.';
comment on column apollo_demo.users.email is 'The email address of the user.';
create table apollo_demo.channels (
id text primary key,
name text not null
);
comment on table apollo_demo.channels is 'A channel of the chat.';
comment on column apollo_demo.channels.id is 'The primary key for the channel.';
comment on column apollo_demo.channels.name is 'The channel’s name.';
create table apollo_demo.messages (
id integer primary key generated by default as identity,
channel_id text not null references apollo_demo.channels(id),
user_id integer not null references apollo_demo.users(id),
content text not null,
date_added timestamp not null default current_timestamp,
date_updated timestamp
) ;
comment on table apollo_demo.messages is 'A message written by a user.';
comment on column apollo_demo.messages.id is 'The primary key for the message.';
comment on column apollo_demo.messages.channel_id is 'The id of the channel.';
comment on column apollo_demo.messages.user_id is 'The id of the user.';
comment on column apollo_demo.messages.content is 'The content this has been posted in.';
comment on column apollo_demo.messages.date_added is 'The time this message was added.';
comment on column apollo_demo.messages.date_updated is 'The time this message was updated';
alter default privileges revoke execute on functions from public;
create function apollo_demo_private.set_date_updated() returns trigger as $$
begin
new.date_updated := current_timestamp;
return new;
end;
$$ language plpgsql;
create trigger messages_date_updated before update
on apollo_demo.messages
for each row
execute procedure apollo_demo_private.set_date_updated();
create table apollo_demo_private.user_accounts (
user_id integer primary key references apollo_demo.users(id) on delete cascade,
password_hash text not null
) ;
comment on table apollo_demo_private.user_accounts is 'Private information about a user’s account.';
comment on column apollo_demo_private.user_accounts.user_id is 'The id of the User associated with this account.';
comment on column apollo_demo_private.user_accounts.password_hash is 'An opaque hash of the user password.';
create extension if not exists "pgcrypto";
create function apollo_demo.user_register(
nickname text,
email text,
password text
) returns apollo_demo.users as $$
declare
usr apollo_demo.users;
begin
insert into apollo_demo.users (nickname, email) values
(user_register.nickname, user_register.email)
returning * into usr;
insert into apollo_demo_private.user_accounts (user_id, password_hash) values
(usr.id, crypt(user_register.password, gen_salt('bf')));
return usr;
end;
$$ language plpgsql strict security definer;
comment on function apollo_demo.user_register(text, text, text) is 'Registers a single user and creates an account in our chat.';
create type apollo_demo.jwt_token as (
role text,
user_id integer,
exp integer
);
create type apollo_demo.usr_and_token as (
usr apollo_demo.users,
token apollo_demo.jwt_token
);
create function apollo_demo.user_login(
email text,
password text
) returns apollo_demo.usr_and_token as $$
select ((users.*)::apollo_demo.users, ('apollo_demo_login_user', users.id, extract(epoch from (now() + interval '60 minutes')))::apollo_demo.jwt_token)::apollo_demo.usr_and_token
from apollo_demo.users
inner join apollo_demo_private.user_accounts
on users.id = user_accounts.user_id
where
users.email = user_login.email
and user_accounts.password_hash = crypt(user_login.password, user_accounts.password_hash);
$$ language sql strict security definer;
comment on function apollo_demo.user_login(text, text) is 'Creates a JWT token that will securely identify a user and give them certain permissions.';
create function apollo_demo.user_current() returns apollo_demo.users as $$
select *
from apollo_demo.users
where id = current_setting('jwt.claims.user_id', true)::integer
$$ language sql stable;
comment on function apollo_demo.user_current() is 'Gets the user who was identified by our JWT.';
create function apollo_demo.change_password(
current_password text,
new_password text
)
returns boolean as $$
declare
usr_current apollo_demo.users;
begin
usr_current := apollo_demo.user_current();
if exists (select 1 from apollo_demo_private.user_accounts where user_accounts.user_id = usr_current.id and user_accounts.password_hash = crypt(change_password.current_password, usr_current.password_hash))
then
update apollo_demo_private.user_accounts set password_hash = crypt(change_password.new_password, gen_salt('bf'))
where user_accounts.user_id = usr_current.id;
return true;
else
return false;
end if;
end;
$$ language plpgsql strict security definer;
create function apollo_demo.user_logout(
) returns boolean as $$
begin
return true;
end;
$$ language plpgsql strict security definer;
create function apollo_demo.message_add(
channel_id text,
content text
) returns apollo_demo.messages as $$
declare
message apollo_demo.messages;
begin
INSERT INTO apollo_demo.messages (channel_id, user_id, content) VALUES
(message_add.channel_id, current_setting('jwt.claims.user_id', true)::integer, message_add.content)
RETURNING messages.* into message;
return message;
end;
$$ language plpgsql strict security definer;
CREATE SEQUENCE mock_message_seq
INCREMENT BY 1
START WITH 1
;
create function apollo_demo.mock_message_send(
) returns boolean as $$
begin
INSERT INTO apollo_demo.messages (channel_id, user_id, content)
SELECT 'general', 0, 'How are you doing? ' || nextval('mock_message_seq');
return true;
end;
$$ language plpgsql strict security definer;
grant usage on schema apollo_demo to apollo_demo_anonymous, apollo_demo_login_user;
grant select on table apollo_demo.users to apollo_demo_login_user;
grant update, delete on table apollo_demo.users to apollo_demo_login_user;
grant select on table apollo_demo.channels to apollo_demo_login_user;
grant insert, update, delete on table apollo_demo.channels to apollo_demo_login_user;
grant select on table apollo_demo.messages to apollo_demo_login_user;
grant insert, update, delete on table apollo_demo.messages to apollo_demo_login_user;
grant execute on function apollo_demo.user_register(text, text, text) to apollo_demo_anonymous;
grant execute on function apollo_demo.user_login(text, text) to apollo_demo_anonymous;
grant execute on function apollo_demo.user_current() to apollo_demo_login_user;
grant execute on function apollo_demo.change_password(text, text) to apollo_demo_login_user;
grant execute on function apollo_demo.user_logout() to apollo_demo_anonymous, apollo_demo_login_user;
grant execute on function apollo_demo.message_add(text, text) to apollo_demo_login_user;
grant execute on function apollo_demo.mock_message_send() to apollo_demo_login_user;
alter table apollo_demo.users enable row level security;
alter table apollo_demo.messages enable row level security;
create policy select_users on apollo_demo.users for select
using (true);
create policy select_messages on apollo_demo.messages for select
using (true);
create policy update_users on apollo_demo.users for update to apollo_demo_login_user
using (id = current_setting('jwt.claims.user_id', true)::integer);
create policy delete_users on apollo_demo.users for delete to apollo_demo_login_user
using (id = current_setting('jwt.claims.user_id', true)::integer);
create policy insert_messages on apollo_demo.messages for insert to apollo_demo_login_user
with check (user_id = current_setting('jwt.claims.user_id', true)::integer);
create policy update_messages on apollo_demo.messages for update to apollo_demo_login_user
using (user_id = current_setting('jwt.claims.user_id', true)::integer);
create policy delete_messages on apollo_demo.messages for delete to apollo_demo_login_user
using (user_id = current_setting('jwt.claims.user_id', true)::integer);
INSERT INTO apollo_demo.users (id, nickname, email) VALUES
(0, 'The Bot', 'bot@bot.com');
INSERT INTO apollo_demo_private.user_accounts (user_id, password_hash) VALUES
(0, crypt('bot', gen_salt('bf')));
INSERT INTO apollo_demo.channels (id, name) VALUES
('general', 'General discussion'),
('random', 'Have fun chatting!'),
('help', 'Ask for or give help');
INSERT INTO apollo_demo.messages (channel_id, user_id, content) VALUES
('general', 0, 'Welcome to the chat!');
create type apollo_demo.message_nullable as (
id integer,
channel_id text,
user_id integer,
content text,
date_added timestamp,
date_updated timestamp
) ;
create or replace function apollo_demo.graphql_subscription() returns trigger as $$
declare
v_process_new bool = (TG_OP = 'INSERT' OR TG_OP = 'UPDATE');
v_process_old bool = (TG_OP = 'UPDATE' OR TG_OP = 'DELETE');
v_event text = TG_ARGV[0];
v_topic_template text = TG_ARGV[1];
v_attribute text = TG_ARGV[2];
v_record record;
v_sub text;
v_topic text;
begin
if v_process_new then
v_record = new;
else
v_record = old;
end if;
if v_attribute is not null then
execute 'select $1.' || quote_ident(v_attribute)
using v_record
into v_sub;
end if;
if v_sub is not null then
v_topic = replace(v_topic_template, '$1', v_sub);
else
v_topic = v_topic_template;
end if;
perform pg_notify(v_topic, json_build_object(
'event', v_event,
'newrec', new.*,
'oldrec', old.*,
'type', TG_OP
)::text);
-- RAISE LOG 'v_topic = %', v_topic;
-- RAISE LOG 'json_build_object = %', json_build_object('event', v_event, 'newrec', new.*, 'oldrec', old.*, 'type', TG_OP);
return v_record;
end;
$$ language plpgsql volatile set search_path from current;
DROP TRIGGER IF EXISTS _500_gql_update_message ON apollo_demo.messages;
CREATE TRIGGER _500_gql_update_message
AFTER INSERT OR UPDATE OR DELETE ON apollo_demo.messages
FOR EACH ROW
EXECUTE PROCEDURE apollo_demo.graphql_subscription(
'messageChanged', -- the "event" string, useful for the client to know what happened
'graphql:message:$1', -- the "topic" the event will be published to, as a template
'channel_id' -- If specified, `$1` above will be replaced with NEW.channel_id or OLD.channel_id from the trigger.
);
commit;
処理が成功したら、psqlと、Laradockのworkspaceからもexitしてホストに戻っておきます。
環境構築&バックエンド実装(その3):PostGraphile関連
PostGraphileは公式Dockerイメージを利用することにします。ただしGraphQLサブスクリプションを動かすために、イメージ内にJavaScriptパッケージを追加インストールする必要があります。そこで、公式イメージをベースイメージにして新たなDockerイメージを作成します。
公式イメージのDockerfileはこちら。
新Dockerfileの置き場所を作成。
mkdir -p ~/work/laradock/postgraphile_plus
Dockerfileを新規作成。
FROM graphile/postgraphile
ENV NODE_PATH=/usr/local/lib/node_modules
# RUN yarn global add @graphile/pg-pubsub graphile-build graphql postgraphile graphile-utils log4js
RUN npm install -g @graphile/pg-pubsub graphile-build graphql postgraphile graphile-utils log4js
ENTRYPOINT ["./cli.js"]
認証にJWTを使用します。
以下を実行して、JWT用共通鍵(秘密鍵)を作成(Linuxコマンド)。この共通鍵は、トークン作成とトークン検証の両方で共通して使用されます。
LC_CTYPE=C < /dev/urandom tr -dc A-Za-z0-9 | head -c32
本ページでは、以下の共通鍵を使用します。秘密鍵なので、本番環境では取り扱い要注意です。
K3INqoxbNWCB4hYiDnX2zlbIpm8UA5zR
Laradockのdocker-compose.ymlファイルの最後に以下を追加。JWT用共通鍵も設定しています。
### PostGraphile ################################################
postgraphile_demo:
build:
context: ./postgraphile_plus
environment:
DATABASE_URL: "postgres://apollo_demo_postgraphile:xyz@postgres:${POSTGRES_PORT}/demodb"
expose:
- "16000"
command: ["--plugins", "@graphile/pg-pubsub", "--append-plugins", "/root/www/vue-apollo/tests/demo/postgraphile-server/DemoSubscriptionPlugin.js", "--subscriptions", "--connection", "postgres://apollo_demo_postgraphile:xyz@postgres:${POSTGRES_PORT}/demodb", "--port", "16000", "--schema", "apollo_demo", "--default-role", "apollo_demo_anonymous", "--secret", "K3INqoxbNWCB4hYiDnX2zlbIpm8UA5zR", "--token", "apollo_demo.jwt_token", "--export-schema-graphql", "/root/www/vue-apollo/tests/demo/postgraphile-server/schema.graphql"]
ports:
- "16000:16000"
depends_on:
- postgres
restart: on-failure:20
networks:
- backend
volumes:
- ${APP_CODE_PATH_HOST}:/root/www
上記docker-compose.ymlで、PostGraphileのプラグインとして「DemoSubscriptionPlugin.js」を使うように設定していますので、そのファイルも作成します。プラグインの内容は後ほどご説明します。
DemoSubscriptionPlugin.jsを以下の内容で新規作成。
ファイルの置き場所は「 ~/work/vue-apollo/tests/demo/postgraphile-server 」とします。
const DEBUG_MODE = false
////////////////////////////////////////////////////////////////
// デバッグ用コード。デバッグ時に /root/www/debug.log にログを出力する。
////////////////////////////////////////////////////////////////
const log4js = require('log4js')
const util = require('util')
log4js.configure({
appenders : {
debug : {type : 'file', filename : '/root/www/debug.log'}
},
categories : {
default : {appenders : ['debug'], level : 'debug'},
}
});
var logger = log4js.getLogger('debug');
////////////////////////////////////////////////////////////////
// 以下、https://www.graphile.org/postgraphile/subscriptions/ の
// MySubscriptionPlugin.jsを改変
////////////////////////////////////////////////////////////////
const { makeExtendSchemaPlugin, gql, embed } = require("graphile-utils");
const makeTopic = (_args, context, _resolveInfo) => {
if (DEBUG_MODE) {
logger.debug('makeTopic() in');
logger.debug('_args = ');
logger.debug(util.inspect(_args));
logger.debug('context = ');
logger.debug(util.inspect(context));
// デバッグ時に、JWTが渡されなくてもSubscriptionを有効にしたいなら、ここでreturn
return `graphql:message:${_args.channelId}`;
}
if (context.jwtClaims.user_id) {
return `graphql:message:${_args.channelId}`;
} else {
throw new Error("You're not logged in");
}
};
module.exports = makeExtendSchemaPlugin(({ pgSql: sql }) => ({
typeDefs: gql`
type MessageChanged {
# This is returned directly from the PostgreSQL subscription payload (JSON object)
type: String!
# This is populated by our resolver below
message: Message
# This is returned directly from the PostgreSQL subscription payload (JSON object)
oldrec: MessageNullable
}
extend type Subscription {
messageChanged (channelId: String!): MessageChanged! @pgSubscription(topic: ${embed(
makeTopic
)})
}
`,
resolvers: {
MessageChanged: {
// This method finds the user from the database based on the event
// published by PostgreSQL.
//
// In a future release, we hope to enable you to replace this entire
// method with a small schema directive above, should you so desire. It's
// mostly boilerplate.
async message(
event,
_args,
_context,
{
graphile: { selectGraphQLResultFromTable },
}
) {
if (DEBUG_MODE) {
logger.debug('message() in');
logger.debug('event = ');
logger.debug(util.inspect(event));
}
if (event.type === 'DELETE') {
return null;
}
const rows = await selectGraphQLResultFromTable(
sql.fragment`apollo_demo.messages`,
(tableAlias, sqlBuilder) => {
if (DEBUG_MODE) {
logger.debug('selectGraphQLResultFromTable() in');
}
sqlBuilder.where(
sql.fragment`${sqlBuilder.getTableAlias()}.id = ${sql.value(
event.newrec.id
)}`
);
}
);
if (DEBUG_MODE) {
logger.debug('rows = ');
logger.debug(util.inspect(rows));
}
return rows[0];
},
},
},
}));
Docker環境の構築
cd ~/work/laradock
docker-compose up -d postgres pgadmin workspace postgraphile_demo
しばらく時間がかかります。終了したら、
docker-compose ps
以下のように表示されました。StateがUpになっているのを確認します。
Name Command State Ports
-----------------------------------------------------------------------------------------------------
laradock_docker-in-docker_1 dockerd-entrypoint.sh Up 2375/tcp
laradock_pgadmin_1 docker-entrypoint.sh pgadmin4 Up 0.0.0.0:5050->5050/tcp
laradock_postgraphile_demo_1 ./cli.js --plugins @graphi Up 0.0.0.0:16000->16000/tcp,
... 5000/tcp
laradock_postgres_1 docker-entrypoint.sh postgres Up 0.0.0.0:5432->5432/tcp
laradock_workspace_1 sudo bash -c [ -e /var/run Up 0.0.0.0:2222->22/tcp,
... 0.0.0.0:3389->3389/tcp,
0.0.0.0:14000->4000/tcp,
0.0.0.0:18080->8080/tcp
PostGraphile が postgres に接続できているか確認します。
docker logs laradock_postgraphile_demo_1
以下のように、最終的にエラーが表示されていなければ、PostGraphileが正常に立ち上がっています。
PostGraphile v4.4.1-alpha.4 server listening on port 16000 🚀
‣ GraphQL API: http://0.0.0.0:16000/graphql (subscriptions enabled)
‣ GraphiQL GUI/IDE: http://0.0.0.0:16000/graphiql
‣ Postgres connection: postgres://apollo_demo_postgraphile:[SECRET]@postgres/demodb
‣ Postgres schema(s): apollo_demo
‣ Documentation: https://graphile.org/postgraphile/introduction/
* * *
何も立ち上がっていない状態からdocker-composeで一斉にコンテナを立ち上げると、PostgreSQLの立ち上げを待たずにPostGraphileが立ち上がろうとして、何回かエラーが発生することがあります。その現象については、「PostGraphile(GraphQL)、Vue.js、Apollo、VuetifyでCRUD機能を実装してみた」に書きましたので、ご参照ください。
さて先程のdocker-compose.ymlでは、GraphQLスキーマを「schema.graphql」にエクスポートするように設定しています。PostGraphileが立ち上がる時に、PostgreSQLのスキーマ情報を自動で読み取ってGraphQLスキーマを生成するのですが、その内容がエクスポートされます。ファイルが存在するか確認してみます。
$ ls ~/work/vue-apollo/tests/demo/postgraphile-server
DemoSubscriptionPlugin.js schema_and_data.sql
schema.graphql
バックエンド実装解説
以上で、オリジナルApolloChatのGraphQLサーバー相当の機能をPostGraphileで実装する作業は完了しています。後はPostGraphileを使うようにフロントエンドのソースコードを変更すれば、PostGraphile版ApolloChatが動くのですが、その前に今までの作業で行ったバックエンドの実装についてご説明します。
(解説は後日書きます)
フロントエンド変更
バックエンドの実装が完了しましたので、次はフロントエンドのVue.jsアプリに手を加えていきます。
オリジナルApolloChatの起動URLは「 http://localhost:8080/ 」でしたが、同じURLにすると混乱するため、PostGraphile版ApolloChatは「 http://localhost:8080/demo/ 」にすることにします。
追記(2019-06-19)
本記事で以後、Altair GraphQL Clientを使ってGraphQL問い合わせを試行しますが、queryとmutationはHEADERSに設定したJWTを認識してくれましたが、subscriptionだけ認識してくれませんでした。記事公開後にGraphql Playgroundの存在を知り、試したところsubscriptionもJWTを認識してくれましたので、こちらを使用した方が楽ですね。(追記終了)
GraphQL問い合わせ文を書き換え、Altair GraphQL Clientで試行する
オリジナルApolloChatが使用するGraphQL問い合わせ文は、以下のディレクトリに格納されています。
$ ls ~/work/vue-apollo/tests/demo/src/graphql
channel.gql messageFragment.gql userLogin.gql
channelFragment.gql messageRemove.gql userLogout.gql
channels.gql messageUpdate.gql userRegister.gql
messageAdd.gql userCurrent.gql
messageChanged.gql userFragment.gql
これらのgqlファイルを、ひとつひとつPostGraphile用に書き換えながら試行します。
問い合わせ文を試行するために、Altair GraphQL Clientをダウンロードしてインストールしてください。
Altair GraphQL Clientを立ち上げ、URL入力欄に「 http://localhost:16000/graphql 」と入力します。
PostGraphileが自動生成したファイル「schema.graphql」にGraphQLスキーマが記述されていますので、GraphQL問い合わせ文を作成するときの参考になります。
以下、変更前後のファイル内容を記載します。
Fragment
userFragment.gql
変更ありません。
fragment user on User {
id
nickname
}
channelFragment.gql
変更ありません。
fragment channel on Channel {
id
name
}
messageFragment.gql
オリジナル
# import "./userFragment.gql"
fragment message on Message {
id
content
user {
...user
}
dateAdded
dateUpdated
}
変更後
# import "./userFragment.gql"
fragment message on Message {
id
content
userByUserId {
...user
}
dateAdded
dateUpdated
}
ログインが不要な問い合わせ
userRegister.gql
機能:新規ユーザー登録
オリジナル
mutation userRegister ($input: UserRegister!) {
userRegister(input: $input)
}
変更後
mutation userRegister ($input: UserRegisterInput!) {
userRegister(input: $input) {
user {
id
nickname
email
}
}
}
VARIABLESに以下を設定して、Altair GraphQL Clientで試行してみます。
{
"input": {
"nickname": "foo",
"email": "foo@foo.com",
"password": "p@ss"
}
}
レスポンス
{
"data": {
"userRegister": {
"user": {
"id": 1,
"nickname": "foo",
"email": "foo@foo.com"
}
}
}
}
userLogout.gql
機能:ログアウト
オリジナル
mutation userLogout {
userLogout
}
変更後
mutation userLogout ($input: UserLogoutInput!) {
userLogout (input: $input) {
boolean
}
}
VARIABLESに以下を設定して、Altair GraphQL Clientで試行してみます。
{
"input": {
}
}
レスポンス
{
"data": {
"userLogout": {
"boolean": true
}
}
}
userLogin.gql
機能:ログイン
オリジナル
# import "./userFragment.gql"
mutation userLogin ($email: String!, $password: String!) {
userLogin (email: $email, password: $password) {
user {
...user
email
}
token {
id
userId
expiration
}
}
}
変更後
mutation userLogin ($input: UserLoginInput!) {
userLogin (input: $input) {
usrAndToken {
usr {
id
nickname
email
}
token
}
}
}
VARIABLESに以下を設定して、Altair GraphQL Clientで試行してみます。
{
"input": {
"email": "foo@foo.com",
"password": "p@ss"
}
}
レスポンス
{
"data": {
"userLogin": {
"usrAndToken": {
"usr": {
"id": 1,
"nickname": "foo",
"email": "foo@foo.com"
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYXBvbGxvX2RlbW9fbG9naW5fdXNlciIsInVzZXJfaWQiOjEsImV4cCI6MTU2MDg0NjM3NiwiaWF0IjoxNTYwODQyNzc2LCJhdWQiOiJwb3N0Z3JhcGhpbGUiLCJpc3MiOiJwb3N0Z3JhcGhpbGUifQ.uE6rXeIWDuFaLaAqE56OisICQKzntl5e4L4znSryoXU"
}
}
}
}
ログインが必要な問い合わせ
先程のログイン処理のレスポンスでJWTトークンが返ってきましたので、それを以下のようにAltair GraphQL ClientのHEADERSに設定します。
Authorization
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYXBvbGxvX2RlbW9fbG9naW5fdXNlciIsInVzZXJfaWQiOjEsImV4cCI6MTU2MDg0NjM3NiwiaWF0IjoxNTYwODQyNzc2LCJhdWQiOiJwb3N0Z3JhcGhpbGUiLCJpc3MiOiJwb3N0Z3JhcGhpbGUifQ.uE6rXeIWDuFaLaAqE56OisICQKzntl5e4L4znSryoXU
以後、問い合わせをして"jwt expired"というレスポンスが返ってきたら、Altair GraphQL ClientのHEADERSからJWTを削除して、再びuserLoginミューテーションを実行し、レスポンスで返ってきたトークンをHEADERSに設定し直してください。
userCurrent.gql
機能:ログイン中のユーザー情報の取得
変更ありません。
# import "./userFragment.gql"
query userCurrent {
userCurrent {
...user
email
}
}
Altair GraphQL Clientで試行してみます。importしているfragmentは直接入力しました。
レスポンス
{
"data": {
"userCurrent": {
"id": 1,
"nickname": "foo",
"email": "foo@foo.com"
}
}
}
channels.gql
機能:全チャンネルを取得
オリジナル
# import "./channelFragment.gql"
query channels {
channels {
...channel
}
}
変更後
# import "./channelFragment.gql"
query channels {
allChannels {
nodes {
...channel
}
}
}
Altair GraphQL Clientで試行してみます。
レスポンス
{
"data": {
"allChannels": {
"nodes": [
{
"id": "general",
"name": "General discussion"
},
{
"id": "help",
"name": "Ask for or give help"
},
{
"id": "random",
"name": "Have fun chatting!"
}
]
}
}
}
channel.gql
機能:チャンネルをidで検索し、そのチャンネルの全メッセージも取得
オリジナル
# import "./channelFragment.gql"
# import "./messageFragment.gql"
query channel ($id: ID!) {
channel (id: $id) {
...channel
messages {
...message
}
}
}
変更後
# import "./channelFragment.gql"
# import "./messageFragment.gql"
query channel ($id: String!) {
channelById (id: $id) {
...channel
messagesByChannelId {
nodes {
...message
}
}
}
}
VARIABLESに以下を設定して、Altair GraphQL Clientで試行してみます。
{
"id": "general"
}
レスポンス
{
"data": {
"channelById": {
"id": "general",
"name": "General discussion",
"messagesByChannelId": {
"nodes": [
{
"id": 1,
"content": "Welcome to the chat!",
"userByUserId": {
"id": 0,
"nickname": "The Bot"
},
"dateAdded": "2019-06-18T07:10:27.671412",
"dateUpdated": null
}
]
}
}
}
}
messageAdd.gql
機能:指定チャンネルでメッセージ追加
オリジナル
# import "./messageFragment.gql"
mutation messageAdd ($input: MessageAdd!) {
messageAdd (input: $input) {
...message
}
}
変更後
# import "./messageFragment.gql"
mutation messageAdd ($input: MessageAddInput!) {
messageAdd (input: $input) {
message {
...message
}
}
}
VARIABLESに以下を設定して、Altair GraphQL Clientで試行してみます。
{
"input": {
"channelId": "general",
"content": "ハローワールド"
}
}
レスポンス
{
"data": {
"messageAdd": {
"message": {
"id": 2,
"content": "ハローワールド",
"userByUserId": {
"id": 1,
"nickname": "foo"
},
"dateAdded": "2019-06-18T07:44:02.534676",
"dateUpdated": null
}
}
}
}
ここで再度channelクエリーを発行すると、「ハローワールド」メッセージが追加されているのが確認できます。
messageUpdate.gql
機能:メッセージ変更(ApolloChatでは未使用)
オリジナル
# import "./messageFragment.gql"
mutation messageUpdate ($input: MessageUpdate!) {
messageUpdate (input: $input) {
...message
}
}
変更後
# import "./messageFragment.gql"
mutation messageUpdate ($input: UpdateMessageByIdInput!) {
updateMessageById (input: $input) {
message {
...message
}
}
}
先程messageAddミューテーションで追加したメッセージに変更を加えてみます。VARIABLESは、
{
"input": {
"messagePatch": {
"content": "こんにちは世界"
},
"id": 2
}
}
レスポンス
{
"data": {
"updateMessageById": {
"message": {
"id": 2,
"content": "こんにちは世界",
"userByUserId": {
"id": 1,
"nickname": "foo"
},
"dateAdded": "2019-06-18T07:44:02.534676",
"dateUpdated": "2019-06-18T07:48:16.794758"
}
}
}
}
ここでchannelクエリーを発行すると、「ハローワールド」メッセージが「こんにちは世界」に変更されているのが確認できます。
messageRemove.gql
機能:メッセージ削除(ApolloChatでは未使用)
オリジナル
# import "./messageFragment.gql"
mutation messageRemove ($id: ID!) {
messageRemove (id: $id) {
...message
}
}
変更後
# import "./messageFragment.gql"
mutation messageRemove ($input: DeleteMessageByIdInput!) {
deleteMessageById (input: $input) {
message {
...message
}
}
}
先程messageUpdateミューテーションで変更したメッセージを削除してみます。VARIABLESは、
{
"input": {
"id": 2
}
}
レスポンス
{
"data": {
"deleteMessageById": {
"message": {
"id": 2,
"content": "こんにちは世界",
"userByUserId": {
"id": 1,
"nickname": "foo"
},
"dateAdded": "2019-06-18T07:44:02.534676",
"dateUpdated": "2019-06-18T07:48:16.794758"
}
}
}
}
ここでchannelクエリーを発行すると、「こんにちは世界」メッセージが削除されたのが確認できます。
messageChanged.gql
機能:サブスクリプションです。指定チャンネルで誰かがメッセージを追加・変更・削除したらサーバーから通知が来ます
オリジナル
# import "./messageFragment.gql"
subscription messageChanged ($channelId: ID!) {
messageChanged (channelId: $channelId) {
type
message {
...message
}
}
}
変更後
# import "./messageFragment.gql"
fragment messageNullable on MessageNullable {
id
channelId
userId
content
dateAdded
dateUpdated
}
subscription messageChanged ($channelId: String!) {
messageChanged (channelId: $channelId) {
type
message {
...message
}
oldrec {
...messageNullable
}
}
}
subscriptionの試行について
Altair GraphQL Clientでは、HEADERSにJWTトークンを設定すればログイン後のqueryやmutationを試行できますが、subscriptionだけはトークンを認識してくれませんでした。
この件についてのつぶやきはこちら:
Altair GraphQL Clientの存在を知った。早速PostGraphile相手に、ヘッダにJWTトークンをセットして使ってみたら、queryとmutationはトークンを認識してくれたけど、subscription(WebSocket)だけ認識してくれなかった..私の使い方がおかしい?でも便利。https://t.co/OP1i6j0X14
— 普通のプログラマ (@kanedaq) 2019年6月7日
Hey, sorry for the confusion. You can't specify headers in subscriptions since it uses websockets (which doesn't allow you specify headers https://t.co/m4iPzKn7bV
— Sir Muel I 🇳🇬 (@imolorhe) 2019年6月7日
You can't pass connection params to the subscription server when starting the subscription for authentication
A description for subscription authentication from Apollo https://t.co/QqE3fDtT7z
— Sir Muel I 🇳🇬 (@imolorhe) 2019年6月7日
こういう事情ですので、ログインしなくてもmessageChangedサブスクリプションを試行できるように、一旦バックエンド側のセキュリティーを緩めることにします。
PostGraphileのプラグイン「DemoSubscriptionPlugin.js」で、DEBUG_MODEをtrueにします。
- const DEBUG_MODE = false
+ const DEBUG_MODE = true
(後略)
変更後のDemoSubscriptionPlugin.jsを有効にするために、PostGraphileコンテナを再起動します。
cd ~/work/laradock
docker stop laradock_postgraphile_demo_1
docker-compose up -d postgres pgadmin workspace postgraphile_demo
これでmessageChangedの受信待ちができるようになりました。
更に、messageChangedが送信データを作る時にPostgreSQLからデータ取得できるよう、一旦PostgreSQL側のセキュリティーも緩めます。
Laradockのworkspaceに入リ、更にpsqlに入ります。
docker-compose exec --user=laradock workspace bash
psql -h postgres -U default demodb
anonymousユーザーに各テーブルのselect権限を与えます。
grant select on table apollo_demo.messages to apollo_demo_anonymous;
grant select on table apollo_demo.users to apollo_demo_anonymous;
grant select on table apollo_demo.channels to apollo_demo_anonymous;
これでmessageChangedが受信できるようになりました。
psqlは引き続き使いますので、立ち上げたままにしておきます。
それでは、VARIABLESに以下を設定して、Altair GraphQL Clientで試行してみます。
{
"channelId": "general"
}
受信待ちの画面:
この状態で、psqlで以下のINSERT文を発行します。
INSERT INTO apollo_demo.messages (channel_id, user_id, content) VALUES
('general', 1, '美味しい!');
レスポンス
{
"data": {
"messageChanged": {
"type": "INSERT",
"message": {
"id": 3,
"content": "美味しい!",
"userByUserId": {
"id": 1,
"nickname": "foo"
},
"dateAdded": "2019-06-18T08:02:26.293796",
"dateUpdated": null
},
"oldrec": null
}
}
}
psqlでINSERTされたデータがレスポンスで返ってきました。
このデータに対して、psqlでUPDATE文を発行します。
UPDATE apollo_demo.messages SET
content = '美味しい!'
WHERE id = 3;
レスポンス
{
"data": {
"messageChanged": {
"type": "UPDATE",
"message": {
"id": 3,
"content": "美味しい!",
"userByUserId": {
"id": 1,
"nickname": "foo"
},
"dateAdded": "2019-06-18T08:02:26.293796",
"dateUpdated": "2019-06-18T08:07:13.937846"
},
"oldrec": {
"id": 3,
"channelId": null,
"userId": null,
"content": "美味しい!",
"dateAdded": null,
"dateUpdated": null
}
}
}
}
更新後のデータがレスポンスで返ってきました。
oldrec(UPDATE前の行)の列の多くがnullになっているのが気になります。今回PostgreSQL側でスキーマ定義する時、列名をスネークケースにしました。PostGraphileはGraphQLスキーマを自動生成する時に、スネークケースをキャメルケースに自動変換します。この変換がなされた列について、値が渡されずにnullになってしまった様子です。AppolloChatではoldrec.idしか使いませんので、値をきちんと渡す実装は今後の宿題ということにして、先に進みます。
返ってきたデータに対して、psqlでDELETE文を発行します。
DELETE FROM apollo_demo.messages
WHERE id = 3;
レスポンス
{
"data": {
"messageChanged": {
"type": "DELETE",
"message": null,
"oldrec": {
"id": 3,
"channelId": null,
"userId": null,
"content": "美味しい!",
"dateAdded": null,
"dateUpdated": null
}
}
}
}
削除についても通知が来ました。
以上でmessageChangedサブスクリプションの試行を終わりにし、先程緩めたセキュリティーを復旧させます。
psqlで以下のREVOKE文を発行します。
revoke select on table apollo_demo.messages from apollo_demo_anonymous;
revoke select on table apollo_demo.users from apollo_demo_anonymous;
revoke select on table apollo_demo.channels from apollo_demo_anonymous;
この状態で、psqlから先程のINSERT文をもう一度発行すると、以下のようにエラーが返ってきて、PostgreSQL側のセキュリティーが復旧したのがわかります。
レスポンス
{
"errors": [
{
"message": "permission denied for table messages",
"locations": [
{
"line": 25,
"column": 5
}
],
"path": [
"messageChanged",
"message"
]
}
],
"data": {
"messageChanged": {
"type": "INSERT",
"message": null,
"oldrec": null
}
}
}
psqlと、Laradockのworkspaceからもexitしてホストに戻っておきます。
Altair GraphQL Clientは、Stopボタンを押してmessageChangedサブスクリプションを停止させます。
次はPostGraphileのプラグイン「DemoSubscriptionPlugin.js」のセキュリティーを復旧させます。
以下のように、DEBUG_MODEをfalseにします。
- const DEBUG_MODE = true
+ const DEBUG_MODE = false
(後略)
変更後のDemoSubscriptionPlugin.jsを有効にするために、PostGraphileコンテナを再起動します。
cd ~/work/laradock
docker stop laradock_postgraphile_demo_1
docker-compose up -d postgres pgadmin workspace postgraphile_demo
この状態でAltair GraphQL ClientでmessageChangedを開始させようとしても、エラーメッセージが返ってきて受信待ちできなくなり、セキュリティーが復旧したのが確認できました。
レスポンス
{
"errors": [
{
"message": "Cannot read property 'user_id' of null",
"locations": [
{
"line": 23,
"column": 3
}
],
"path": [
"messageChanged"
]
}
]
}
~/work/vue-apollo/tests/demo/src/components/MockSendMessage.vue
これまでのgqlファイル以外に、Vueコンポーネントのソースコードに直接書かれた問い合わせがありますので、それも書き換えながら試行します。
問い合わせの機能:generalチャンネルに「"How are you doing? " + 連番」というメッセージを投稿
オリジナル
<script>
import gql from 'graphql-tag'
export default {
created () {
this.SEND = gql`
mutation mockMessageSend {
mockMessageSend
}
`
},
}
</script>
<template>
<ApolloMutation
:mutation="SEND"
class="mock-send-message"
>
<a
slot-scope="{ mutate }"
type="button"
@click="mutate"
>Send bot message</a>
</ApolloMutation>
</template>
<style lang="stylus" scoped>
.mock-send-message
margin-top 8px
display flex
align-items center
justify-content center
</style>
変更後
<script>
import gql from 'graphql-tag'
export default {
created () {
this.SEND = gql`
mutation mockMessageSend ($input: MockMessageSendInput!) {
mockMessageSend (input: $input) {
boolean
}
}
`
},
}
</script>
<template>
<ApolloMutation
:mutation="SEND"
:variables="{
input: {
},
}"
class="mock-send-message"
>
<a
slot-scope="{ mutate }"
type="button"
@click="mutate"
>Send bot message</a>
</ApolloMutation>
</template>
<style lang="stylus" scoped>
.mock-send-message
margin-top 8px
display flex
align-items center
justify-content center
</style>
GraphQL問い合わせ文を抜き出し、VARIABLESに以下を設定して、Altair GraphQL Clientで試行してみます。
{
"input": {
}
}
レスポンス
{
"data": {
"mockMessageSend": {
"boolean": true
}
}
}
この試行でgeneralチャンネルに「"How are you doing? " + 連番」が投稿されたかどうかは、後にPostGraphile版ApolloChatを起動した時に確認することにします。
以上で、GraphQL問い合わせ文の書き換えと試行が完了しました。
フロントエンドのGraphQL問い合わせ以外のソースコード書き換え
それでは、問い合わせ文以外の変更ということで、以下のようにファイルを作成・変更していきます。
~/work/vue-apollo/tests/demo/vue.config.js (変更)
CORSエラーを避けるために、devServerのproxyの部分でリバースプロキシを設定しました(WebSocketとhttpの両方)。
module.exports = {
pluginOptions: {
graphqlMock: false,
apolloEngine: false,
},
+ devServer: {
+ host: '0.0.0.0',
+ disableHostCheck: true,
+ proxy: {
+ "^/postgraphile/demo/ws": {
+ target: "http://localhost:16000",
+ ws: true,
+ changeOrigin: true,
+ pathRewrite: {
+ "^/postgraphile/demo/ws": "/"
+ }
+ },
+ "^/postgraphile/demo": {
+ target: "http://localhost:16000",
+ ws: false,
+ pathRewrite: {
+ "^/postgraphile/demo": "/"
+ }
+ },
+ }
+ },
+ publicPath: '/demo',
/* Without vue-cli-plugin-apollo 0.20.0+ */
// chainWebpack: config => {
// config.module
// .rule('vue')
// .use('vue-loader')
// .loader('vue-loader')
// .tap(options => {
// options.transpileOptions = {
// transforms: {
// dangerousTaggedTemplateString: true,
// },
// }
// return options
// })
// }
}
参考までに、リバースプロキシをvue.config.jsでなくLaradockのnginxで設定する場合は、「 ~/work/laradock/nginx/site/default.conf 」に以下の設定を加えて、docker-compose立ち上げ時にnginxコンテナも立ち上げ、その後workspaceコンテナに入ってyarn serveでWebサーバーを立ち上げ、ホストのブラウザから「 http://localhost/demo/ 」にアクセスしてPostGraphile版ApolloChatを起動します。
location /demo/ {
proxy_pass http://workspace:8080/demo/;
}
location /postgraphile/demo/ws/ {
proxy_pass http://postgraphile_demo:16000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /postgraphile/demo/ {
proxy_pass http://postgraphile_demo:16000/;
}
~/work/vue-apollo/tests/demo/.env (新規)
内容:接続するPostGraphileのエンドポイントの設定。
VUE_APP_GRAPHQL_HTTP=http://localhost:8080/postgraphile/demo/graphql
VUE_APP_GRAPHQL_WS=ws://localhost:8080/postgraphile/demo/ws/graphql
この内容は、~/work/vue-apollo/tests/demo/src/vue-apollo.jsから参照されます。
参考までに、リバースプロキシをvue.config.jsでなくLaradockのnginxで設定する場合は、.envを以下のように設定します。
VUE_APP_GRAPHQL_HTTP=http://localhost/postgraphile/demo/graphql
VUE_APP_GRAPHQL_WS=ws://localhost/postgraphile/demo/ws/graphql
~/work/vue-apollo/tests/demo/.graphqlconfig.yml (変更)
PostGraphileが自動生成したGraphQLスキーマファイルの場所をApolloChatに教えてあげます。
projects:
app:
- schemaPath: apollo-server/schema.graphql
+ schemaPath: postgraphile-server/schema.graphql
includes:
- '**/*.gql'
extensions:
endpoints:
default: 'http://localhost:4000/graphql'
~/work/vue-apollo/tests/demo/src/router.js (変更)
Vue.jsプロジェクトをdemoサブディレクトリで配信するために必要な1行を加えます。
import Vue from 'vue'
import Router from 'vue-router'
import UserLogin from './components/UserLogin.vue'
import WelcomeView from './components/WelcomeView.vue'
import ChannelView from './components/ChannelView.vue'
Vue.use(Router)
export default new Router({
mode: 'history',
+ base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: WelcomeView,
},
{
path: '/login',
name: 'login',
component: UserLogin,
},
{
path: '/chan/:id',
name: 'channel',
component: ChannelView,
props: true,
},
],
})
~/work/vue-apollo/tests/demo/src/vue-apollo.js (変更)
import Vue from 'vue'
import VueApollo from '../../../'
import { createApolloClient, restartWebsockets } from 'vue-cli-plugin-apollo/graphql-client'
+ import { InMemoryCache } from 'apollo-cache-inmemory'
// Install the vue plugin
Vue.use(VueApollo)
// Name of the localStorage item
- const AUTH_TOKEN = 'apollo-token'
+ const AUTH_TOKEN = 'postgraphile-demo-token'
// Config
const defaultOptions = {
// You can use `https` for secure connection (recommended in production)
httpEndpoint: process.env.VUE_APP_GRAPHQL_HTTP || 'http://localhost:4000/graphql',
// You can use `wss` for secure connection (recommended in production)
// Use `null` to disable subscriptions
wsEndpoint: process.env.VUE_APP_GRAPHQL_WS || 'ws://localhost:4000/graphql',
// LocalStorage token
tokenName: AUTH_TOKEN,
// Enable Automatic Query persisting with Apollo Engine
persisting: false,
// Use websockets for everything (no HTTP)
// You need to pass a `wsEndpoint` for this to work
websocketsOnly: false,
// Is being rendered on the server?
ssr: false,
// Override default http link
// link: myLink,
// Override default cache
// cache: myCache,
+ cache: new InMemoryCache(),
// Additional ApolloClient options
// apollo: { ... }
getAuth: tokenName => {
// get the authentication token from local storage if it exists
const token = localStorage.getItem(tokenName)
// return the headers to the context so httpLink can read them
- return token || ''
+ return token ? ('Bearer ' + token) : ''
},
}
// Call this in the Vue app file
export function createProvider (options = {}, { router }) {
// Create apollo client
const { apolloClient, wsClient } = createApolloClient({
...defaultOptions,
...options,
})
apolloClient.wsClient = wsClient
// Create vue apollo provider
const apolloProvider = new VueApollo({
defaultClient: apolloClient,
defaultOptions: {
$query: {
fetchPolicy: 'cache-and-network',
},
},
errorHandler (error) {
if (isUnauthorizedError(error)) {
// Redirect to login page
if (router.currentRoute.name !== 'login') {
router.replace({
name: 'login',
params: {
wantedRoute: router.currentRoute.fullPath,
},
})
}
} else {
console.log('%cError', 'background: red; color: white; padding: 2px 4px; border-radius: 3px; font-weight: bold;', error.message)
}
},
})
return apolloProvider
}
// Manually call this when user log in
export async function onLogin (apolloClient, token) {
- localStorage.setItem(AUTH_TOKEN, JSON.stringify(token))
+ localStorage.setItem(AUTH_TOKEN, token)
if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient)
try {
await apolloClient.resetStore()
} catch (e) {
if (!isUnauthorizedError(e)) {
console.log('%cError on cache reset (login)', 'color: orange;', e.message)
}
}
}
// Manually call this when user log out
export async function onLogout (apolloClient) {
localStorage.removeItem(AUTH_TOKEN)
if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient)
try {
await apolloClient.resetStore()
} catch (e) {
if (!isUnauthorizedError(e)) {
console.log('%cError on cache reset (logout)', 'color: orange;', e.message)
}
}
}
function isUnauthorizedError (error) {
- const { graphQLErrors } = error
- return (graphQLErrors && graphQLErrors.some(e => e.message === 'Unauthorized'))
+ if (error) {
+ if (error.message.indexOf('Unauthorized') >= 0 || error.message.indexOf('permission denied') >= 0 || error.message.indexOf('status code 401') >= 0) {
+ return true
+ }
+ }
+ return false
}
~/work/vue-apollo/tests/demo/src/components/UserLogin.vue (変更)
<script>
import UserCurrent from '../mixins/UserCurrent'
import USER_CURRENT from '../graphql/userCurrent.gql'
import { onLogin } from '../vue-apollo'
export default {
name: 'UserLogin',
mixins: [
UserCurrent,
],
data () {
return {
showRegister: false,
email: '',
password: '',
nickname: '',
}
},
watch: {
// If already logged in redirect to other page
userCurrent (value) {
if (value) {
this.redirect()
}
},
},
methods: {
async onDone (result) {
if (this.showRegister) {
this.showRegister = false
} else {
if (!result.data.userLogin) return
const apolloClient = this.$apollo.provider.defaultClient
// Update token and reset cache
- const { id, userId, expiration } = result.data.userLogin.token
- await onLogin(apolloClient, { id, userId, expiration })
+ await onLogin(apolloClient, result.data.userLogin.usrAndToken.token)
// Update cache
apolloClient.writeQuery({
query: USER_CURRENT,
data: {
- userCurrent: result.data.userLogin.user,
+ userCurrent: result.data.userLogin.usrAndToken.usr,
},
})
}
},
redirect () {
this.$router.replace(this.$route.params.wantedRoute || { name: 'home' })
},
},
}
</script>
<template>
<div class="user-login">
<div class="logo">
<i class="material-icons icon">chat</i>
</div>
<div class="app-name">
Apollo<b>Chat</b>
</div>
<ApolloMutation
:mutation="showRegister
? require('../graphql/userRegister.gql')
: require('../graphql/userLogin.gql')"
:variables="showRegister
? {
input: {
email,
password,
nickname,
},
}
: {
- email,
- password,
+ input: {
+ email,
+ password,
+ },
}"
class="wrapper"
@done="onDone"
>
<form
slot-scope="{ mutate, loading, gqlError: error }"
:key="showRegister"
class="form"
@submit.prevent="mutate()"
>
<input
v-model="email"
class="form-input"
type="email"
name="email"
placeholder="Email"
required
>
<input
v-model="password"
class="form-input"
type="password"
name="password"
placeholder="Password"
required
>
<input
v-if="showRegister"
v-model="nickname"
class="form-input"
name="nickname"
placeholder="Nickname"
required
>
<div v-if="error" class="error">{{ error.message }}</div>
<template v-if="!showRegister">
<button
type="submit"
:disabled="loading"
class="button"
data-id="login"
>Login</button>
<div class="actions">
<a
data-id="create-account"
@click="showRegister = true"
>Create an account</a>
</div>
</template>
<template v-else>
<button
type="submit"
:disabled="loading"
class="button"
data-id="submit-new-account"
>Create new account</button>
<div class="actions">
<a @click="showRegister = false">Go back</a>
</div>
</template>
</form>
</ApolloMutation>
</div>
</template>
<style lang="stylus" scoped>
@import '~@/style/imports'
.user-login
height 100%
display flex
flex-direction column
align-items center
justify-content center
.logo
.icon
font-size 80px
color $color
.app-name
font-size 42px
font-weight lighter
margin-bottom 32px
.wrapper
flex auto 0 0
.form
width 100vw
max-width 300px
.form-input,
.button
display block
width 100%
box-sizing border-box
.form-input
margin-bottom 12px
.actions
margin-top 12px
text-align center
font-size 12px
</style>
~/work/vue-apollo/tests/demo/src/components/UserCurrent.vue (変更)
<script>
import UserCurrent from '../mixins/UserCurrent'
import { onLogout } from '../vue-apollo'
import USER_LOGOUT from '../graphql/userLogout.gql'
export default {
name: 'MessageForm',
mixins: [
UserCurrent,
],
methods: {
async logout () {
+ const apolloClient = this.$apollo.provider.defaultClient
+ onLogout(apolloClient)
await this.$apollo.mutate({
mutation: USER_LOGOUT,
+ variables: {
+ input: {
+ },
+ },
})
- const apolloClient = this.$apollo.provider.defaultClient
- onLogout(apolloClient)
},
},
}
</script>
<template>
<div class="user-current">
<template v-if="userCurrent">
<i class="material-icons user-icon">account_circle</i>
<div class="info">
<div class="nickname">{{ userCurrent.nickname }}</div>
<div class="email">{{ userCurrent.email }}</div>
</div>
<button
class="icon-button"
data-id="logout"
@click="logout()"
>
<i class="material-icons">power_settings_new</i>
</button>
</template>
</div>
</template>
<style lang="stylus" scoped>
@import '~@/style/imports'
.user-current
color white
display grid
grid-template-columns auto 1fr auto
grid-template-rows auto
grid-gap 12px
align-items center
margin-bottom 20px
padding 12px 0 12px 12px
.email
font-size 12px
.icon-button
&:not(:hover)
background none
</style>
~/work/vue-apollo/tests/demo/src/components/MessageForm.vue (変更)
<script>
- import MESSAGE_FRAGMENT from '../graphql/messageFragment.gql'
- import USER_FRAGMENT from '../graphql/userFragment.gql'
-
export default {
props: {
channelId: {
type: String,
required: true,
},
},
data () {
return {
newMessage: '',
}
},
methods: {
onDone () {
this.newMessage = ''
this.$refs.input.focus()
},
},
-
- fragments: {
- message: MESSAGE_FRAGMENT,
- user: USER_FRAGMENT,
- },
}
</script>
<template>
<ApolloMutation
- :mutation="gql => gql`
- mutation messageAdd ($input: MessageAdd!) {
- messageAdd (input: $input) {
- ...message
- }
- }
- ${$options.fragments.message}
- ${$options.fragments.user}
- `"
+ :mutation="require('../graphql/messageAdd.gql')"
:variables="{
input: {
channelId,
content: newMessage,
},
}"
class="message-form"
@done="onDone"
>
<input
slot-scope="{ mutate, loading, error }"
ref="input"
v-model="newMessage"
:disabled="loading"
class="form-input"
placeholder="Type a message"
@keyup.enter="newMessage && mutate()"
>
</ApolloMutation>
</template>
<style lang="stylus" scoped>
@import '~@/style/imports'
.message-form
padding 12px
width 100%
box-sizing border-box
.form-input
display block
box-sizing border-box
width 100%
</style>
~/work/vue-apollo/tests/demo/src/components/MessageItem.vue (変更)
<script>
import marked from 'marked'
// Open links in new tab
const renderer = new marked.Renderer()
const linkRenderer = renderer.link
renderer.link = (href, title, text) => {
const html = linkRenderer.call(renderer, href, title, text)
return html.replace(/^<a /, '<a target="_blank" rel="nofollow" ')
}
export default {
props: {
message: {
type: Object,
required: true,
},
},
computed: {
html () {
return marked(this.message.content, { renderer })
},
},
}
</script>
<template>
<div class="message-item">
- <div class="user">{{ message.user.nickname }}</div>
+ <div class="user">{{ message.userByUserId.nickname }}</div>
<div class="content" v-html="html"/>
</div>
</template>
<style lang="stylus" scoped>
@import '~@/style/imports'
.message-item
padding 12px 12px
&:hover
background #f8f8f8
.user
color #777
font-size 13px
margin-bottom 2px
.content
word-wrap break-word
>>>
p
margin 0
img
max-width 500px
max-height 500px
</style>
~/work/vue-apollo/tests/demo/src/components/ChannelList.vue (変更)
<script>
import UserCurrent from './UserCurrent.vue'
import MockSendMessage from './MockSendMessage.vue'
- import gql from 'graphql-tag'
export default {
name: 'ChannelList',
components: {
UserCurrent,
MockSendMessage,
},
-
- fragments: {
- channel: gql`
- fragment channel on Channel {
- id
- name
- }
- `,
- },
}
</script>
<template>
<div class="channel-list">
<UserCurrent />
- <ApolloQuery :query="gql => gql`
- query channels {
- channels {
- ...channel
- }
- }
- ${$options.fragments.channel}
- `">
+ <ApolloQuery :query="require('../graphql/channels.gql')">
<template slot-scope="{ result: { data, loading } }">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="data" class="channels">
<router-link
- v-for="channel of data.channels"
+ v-for="channel of data.allChannels.nodes"
:key="channel.id"
:to="{ name: 'channel', params: { id: channel.id } }"
class="channel"
>
<div class="id">#{{ channel.id }}</div>
<div class="name">{{ channel.name }}</div>
</router-link>
</div>
</template>
</ApolloQuery>
<MockSendMessage/>
</div>
</template>
<style lang="stylus" scoped>
@import '~@/style/imports'
.channel-list
background desaturate(darken($color, 60%), 95%)
color white
padding 12px
.channel
display block
padding 12px
border-radius 4px
&:hover
background rgba($color, .3)
&.router-link-active
background $color
color white
font-weight bold
.id
font-family monospace
margin-bottom 4px
font-size 14px
.name
font-size 12px
opacity .9
</style>
~/work/vue-apollo/tests/demo/src/components/ChannelView.vue (変更)
<script>
import MessageItem from './MessageItem.vue'
import MessageForm from './MessageForm.vue'
export default {
name: 'ChannelView',
components: {
MessageItem,
MessageForm,
},
props: {
id: {
type: String,
required: true,
},
},
watch: {
id: {
handler () {
this.$_init = false
},
immediate: true,
},
},
methods: {
onMessageChanged (previousResult, { subscriptionData }) {
- const { type, message } = subscriptionData.data.messageChanged
-
- // No list change
- if (type === 'updated') return previousResult
-
- const messages = previousResult.channel.messages.slice()
- // Add or remove item
- if (type === 'added') {
- messages.push(message)
- } else if (type === 'removed') {
- const index = messages.findIndex(m => m.id === message.id)
- if (index !== -1) messages.splice(index, 1)
- }
-
- // New query result
- return {
- channel: {
- ...previousResult.channel,
- messages,
- },
- }
+ const { message, oldrec, type } = subscriptionData.data.messageChanged
+
+ const nodes = previousResult.channelById.messagesByChannelId.nodes.slice()
+ if (type === 'INSERT') {
+ nodes.push(message)
+ } else {
+ const index = nodes.findIndex(m => m.id === oldrec.id)
+ if (index !== -1) {
+ if (type === 'DELETE') {
+ nodes.splice(index, 1)
+ } else if (type === 'UPDATE') {
+ nodes.splice(index, 1, message)
+ }
+ }
+ }
+
+ // New query result
+ return {
+ channelById: {
+ ...previousResult.channelById,
+ messagesByChannelId: {
+ ...previousResult.channelById.messagesByChannelId,
+ nodes,
+ },
+ },
+ }
},
async scrollToBottom (force = false) {
let el = this.$refs.body
// No body element yet
if (!el) {
setTimeout(() => this.scrollToBottom(force), 100)
return
}
// User is scrolling up => no auto scroll
if (!force && el.scrollTop + el.clientHeight < el.scrollHeight - 100) return
// Scroll to bottom
await this.$nextTick()
el.scrollTop = el.scrollHeight
},
onResult (result) {
// The first time we load a channel, we force scroll to bottom
this.scrollToBottom(!this.$_init)
this.$_init = true
},
},
}
</script>
<template>
<div class="channel-view">
<ApolloQuery
:query="require('../graphql/channel.gql')"
:variables="{
id
}"
@result="onResult"
>
<template slot-scope="{ result: { data, loading } }">
<div v-if="!data && loading" class="loading">Loading...</div>
<div v-else-if="data">
<!-- Websockets -->
<ApolloSubscribeToMore
:document="require('../graphql/messageChanged.gql')"
:variables="{
channelId: id,
}"
:updateQuery="onMessageChanged"
/>
<div class="wrapper">
<div class="header">
- <div class="id">#{{ data.channel.id }}</div>
- <div class="name">{{ data.channel.name }}</div>
+ <div class="id">#{{ data.channelById.id }}</div>
+ <div class="name">{{ data.channelById.name }}</div>
</div>
<div ref="body" class="body">
<MessageItem
- v-for="message in data.channel.messages"
+ v-for="message in data.channelById.messagesByChannelId.nodes"
:key="message.id"
:message="message"
/>
</div>
<div class="footer">
<MessageForm :channel-id="id" />
</div>
</div>
</div>
</template>
</ApolloQuery>
</div>
</template>
<style lang="stylus" scoped>
@import '~@/style/imports'
.wrapper
height 100vh
display grid
grid-template-columns 1fr
grid-template-rows auto 1fr auto
.header
padding 12px
border-bottom $border
.id
font-family monospace
margin-bottom 4px
.name
color #555
.body
overflow-x hidden
overflow-y auto
.footer
border-top $border
</style>
以上でソースコードの変更は完了です。
PostGraphile版ApolloChatの実行
それでは動かしてみます。
以下を実行して、Webサーバー(Vue.jsアプリ配信)を立ち上げます。
PostGraphileがDockerコンテナで立ち上がっていますので、別途GraphQLサーバーを立ち上げる必要はありません。
cd ~/work/vue-apollo/tests/demo
yarn serve
正常にコンパイルされることを確認します。
コンパイル後、以下のように表示されました。
DONE Compiled successfully in 3593ms 10:31:39
App running at:
- Local: http://localhost:8080/demo/
- Network: unavailable
Note that the development build is not optimized.
To create a production build, run yarn build.
表示に従い、ブラウザで「 http://localhost:8080/demo/ 」にアクセスすると、PostGraphile版ApolloChatが立ち上がります。
まずログイン画面が現れます。
Altair GraphQL Clientから作成したアカウントでログインしてみます。
Email:foo@foo.com
Password:p@ss
generalチャンネルに入ってみます。
GraphQL問い合わせを試行した時のメッセージが残っていました。
- messageChangedサブスクリプションを試行した時に、psqlからINSERTした「美味しい!」メッセージ
- mockMessageSendミューテーションをAltair GraphQL Clientで試行した時に投稿された「"How are you doing? " + 連番」
では、左下の「Send bot message」を押してみます。
「"How are you doing? " + 連番」が1つ増えました。
右下のメッセージ入力欄から「こんにちは世界」と投稿してみます。
「こんにちは世界」が投稿されました。
ではサブスクリプションを試してみます。
コンソールからLaradockのworkspaceに入リ、更にpsqlに入ります。
cd ~/work/laradock
docker-compose exec --user=laradock workspace bash
psql -h postgres -U default demodb
以下のSQLを発行して、メッセージ一覧を表示してみます。
SELECT * FROM apollo_demo.messages
ORDER BY id;
以下のように表示されました。
id | channel_id | user_id | content | date_added | date_updated
----+------------+---------+----------------------+----------------------------+--------------
1 | general | 0 | Welcome to the chat! | 2019-06-18 07:10:27.671412 |
4 | general | 1 | 美味しい! | 2019-06-18 08:13:57.466309 |
5 | general | 0 | How are you doing? 1 | 2019-06-18 08:26:07.808588 |
6 | general | 0 | How are you doing? 2 | 2019-06-19 01:44:42.855632 |
7 | general | 1 | こんにちは世界 | 2019-06-19 02:33:33.951225 |
id=5の行を更新して、ApolloChatのデータが更新されるか試してみます。以下のSQLを発行します。
UPDATE apollo_demo.messages SET
content = 'Thank you.'
WHERE id = 5;
ApolloChatの画面も更新されました。
次はid=5の行を削除してみます。以下のSQLを発行します。
DELETE FROM apollo_demo.messages
WHERE id = 5;
ApolloChatの画面からも削除されました。
次は新しい行を挿入してみます。以下のSQLを発行します。
INSERT INTO apollo_demo.messages (channel_id, user_id, content) VALUES
('general', 1, 'Hello, World!');
ApolloChatの画面にも追加されました。