AWS AppSync をシンプルなJavaScriptで試してみる

# はじめに
こんにちは、Serverless Advent Calendar 2018 の 19日目 です。
今まで見ることしかしていなかったAdvent Calendarですが、
大好きなServerlessについて、初めて記事を書くことを決意しました。


# 私と Serverless Advent Calendar 2018
・ケチかつ飽き性なので、従量課金のServerlessに興味をもつ
・モバイルアプリを作ろうとしたが、認証／ソケット／Push通知あたりで手こずる
・DynamoDBの設計がイマイチでAPIの数も増える一方…（この辺りでやる気なくなってきた）
・[DevDayTokyo2018のServerlessDay](https://qiita.com/siruko/items/1a7841fd631aff33fbc4)に参加
・その中のセッションで[AWS AppSyncの素晴らしさ](https://www.slideshare.net/AmazonWebServicesJapan/graphql-aws-appsync) を感じ、 [DynamoDBの設計指針](https://speakerdeck.com/marcyterui/lets-become-the-master-of-dynamodb-data-modeling-to-become-the-master-of-serverless)も固まった
・この熱が冷める前にAdventCalendarに参加！

# 私の経験
・10年目のシステムエンジニア
・業務ではAWSは利用していない
・APIGateway＋Lambda＋DynamoDBは業務外で少し経験あり（SAM）
・AWS AppSync初（Cognito初、GraphQL初）

# さっそくチュートリアル
以下をゴールとし、理解を深めるために「なるべく」手作りする
・Cognitoでユーザ認証（メール認証、Google連携）
・GraphQL経由でのデータアクセス、更新（認証ユーザでのデータアクセス制御）
・GraphQLのサブスクリプションによる通知

## 順序
・Cognito UserPoolの作成（AppSyncの設定に必要）
・Appsync でAPIの作成
・WebサイトからGraphQLでのデータアクセス、サブスクリプションを試す

## Cognito UserPoolの作成
マネジメントコンソールから「ユーザープールを作成」で作成開始
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/001be162-ae26-e6a4-5270-9dda0877ff70.png)

適当な名称をつけて、「デフォルトを確認する」で作る
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/f8583d0c-9c71-c29e-a018-49ab3aceb64e.png)

「属性」を修正する
ユーザのサインインは「メールアドレス」とする（一般的、かつ、Google連携との相性を考慮）
emailは必須属性とする
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/3530e31a-35f6-bb4a-b14e-a0a859095a61.png)

「アプリクライアント」を追加する
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/5062ba12-a58d-9757-2070-935f99af774d.png)
※Webから繋ごうとしているので、クライアントシークレットは生成しない
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/d57b295d-76e1-7197-a272-be5b6b3a09ee.png)

「確認」からプールを作成する
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/f5b5e53d-98bb-b977-17d2-13dbf492150e.png)

以下をメモしておく
「プールID」　⇨　リージョン_ランダム文字列：us-west-2_xxxxxxxxx
「アプリクライアントID」　⇨　ランダム文字列：xxxxxxxxxxxxxxxxxxxxxxxxxx

#### メール認証でユーザを作成する
認証を行うために、Cognitoで用意しているフォームを使う
「アプリの統合」⇨「アプリクライアントの設定」
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/55854531-5f41-e699-1f61-32b804c264d9.png)
↓
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/7822e85d-134b-7add-0d5b-00919001be81.png)
※コールバックURLは適当だが、認証用URLのパラメータで完全一致させる必要があるので注意

認証用のドメインを設定する
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/9357f9ff-e427-e9e7-33aa-d38fb2bf2ee8.png)

認証用ページに行ってみる
https://<font color="Red">*設定したドメイン*</font>.auth.<font color="Red">*リージョン*</font>.amazoncognito.com/login?response_type=code&client_id=<font color="Red">*クライアントID*</font>&redirect_uri=http://<font color="Red">*リダイレクトURL*</font>/
Ex. https://test.auth.us-west-2.amazoncognito.com/login?response_type=code&client_id=xxxxxxxxxxxxxxxxxxxxxxxxxx&redirect_uri=https://aws.amazon.com/jp/
<img src="https://qiita-image-store.s3.amazonaws.com/0/217144/2bcf4bab-f097-d88e-1882-ee4f45dd4024.png" width="300px">

サインアップする
<img src="https://qiita-image-store.s3.amazonaws.com/0/217144/941c03b8-1e2d-dd91-7545-bdc7e2b8b9fe.png" width="300px">

入力したメールアドレスに送信された認証コードを入れる
<img src="https://qiita-image-store.s3.amazonaws.com/0/217144/975c0c70-daef-b77d-1fd7-4a4db84c10bb.png" width="300px">

リダイレクトURLに遷移する
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/7f0c4a31-9410-0739-c16f-d46f5fc22d04.png)

ユーザープールの「ユーザーとグループ」に追加されている
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/32e68cb5-3e9a-6ef8-3db9-78976be9d6d8.png)

#### Google連携でユーザを作成する
GCPアカウントを作り、プロジェクトを作成し、「認証情報」に行く
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/1fa85192-e8ef-daa6-c33e-a125d72aba71.png)

初めての場合は「OAuth同意画面」からドメインを登録しておく
<img src="https://qiita-image-store.s3.amazonaws.com/0/217144/42afb18d-b3df-8988-d32e-006ec11334e9.png" width="300px">

「認証情報」に戻り、OAuthクライアントIDを作成する
<img src="https://qiita-image-store.s3.amazonaws.com/0/217144/b071d145-ec57-ff3f-66bb-2b9b463c1dda.png" width="300px">
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/80529b7b-c4e2-be55-9296-477394309e73.png)

承認済みのリダイレクトURIには、以下を設定する
https://<font color="Red">*設定したドメイン*</font>.auth.<font color="Red">*リージョン*</font>.amazoncognito.com/oauth2/idpresponse

作成するとクライアントIDとシークレットが表示されるので、メモしておく
<img src="https://qiita-image-store.s3.amazonaws.com/0/217144/e3d12006-bde2-4641-5b10-516f63e70ef3.png" width="300px">

AWSのマネジメントコンソールに戻り、ユーザープールの「IDプロパイダー」から、Googleを選択する
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/2def887e-4971-9b42-e3cf-2cf1c696bcaa.png)
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/2bdda66a-37ae-1a98-c5bf-2e44bda8bfea.png)
・GoogleアプリID：GCPで取得したクライアントID
・アプリシークレット：GCPで取得したシークレット
・承認スコープ："profile email openid"

「属性マッピング」の Googleタブでemailを設定し、Googleからメールアドレスを取得できるようにする
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/1e2b4218-4224-9ba9-2cd9-274caee053b3.png)

「アプリクライアントの設定」の有効なIDプロパイダにGoogleが追加されているので、チェックを入れる
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/b7bb3580-3305-45e1-670d-aa43ac3fdcf1.png)

認証用URLに遷移すると、Googleが新たに追加されている
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/e42dddef-2b8e-4d15-8703-0a22fdf8a1b3.png)


## Appsync の設定
マネジメントコンソールから「Create API」で作っていく
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/d090eb7e-ef9d-0a0f-eef1-1d5227ed9cd1.png)

「Create with wizard」を選択してStart
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/a765f3c5-cedf-7682-44d0-245292467531.png)

デフォルトで作成する（モデルは後から変えられる）
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/c53e8547-1740-dba7-6165-50ffc72f11e5.png)

AppSyncが作成されるので「Setting」で設定を確認し、メモしておく
API URL
API ID

AppSyncのAuthorization typeをCognitoに変更し、作成したユーザプールを指定する
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/84c1ef27-ea58-6d62-9588-1af9d1338d07.png)

「Queries」からクエリを実行しようとすると、UnauthorizedExceptionとなる
"Login With User Pools"からログインすると、クエリを実行できるようになる
<img src="https://qiita-image-store.s3.amazonaws.com/0/217144/52b5b399-68ef-eabe-80d2-ecf34e1c025c.png" width="300px">

## Webサイトからcognitoでログイン＆GraphQLでのデータ取得／登録を試す
GraphQLを呼び出すには、jwt（アクセストークン）が必要になる
jwtは、認証用ページで認証をした後にリダイレクトURLに遷移した際のクエリパラメータ「code」を使うことで、cognitoのエンドポイントから取得することができる


↓ ログイン処理までを以下の順で説明する
- 外部javascriptの読み込み
- 独自関数群
- ログイン処理

```javascript:index.html
<html>
<head>
  <meta charset="UTF-8">
  <!-- 外部javascriptの読み込み -->
  <script
  src="https://code.jquery.com/jquery-3.3.1.min.js"
  integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
  crossorigin="anonymous"></script>
  <script type="module" src="src/aws-exports.js"></script>
  <script src="src/aws-sdk.js"></script>
  <script src="src/config.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.min.js" type="text/javascript"></script>

  <!-- 独自関数群 -->
  <script>
    var _resolve = function(x){ return $.Deferred().resolve(x).promise(); };
    var _reject = function(x){ return $.Deferred().reject(x).promise(); };
    var _getStorageItem = function(x){
      var _item = JSON.parse(localStorage.getItem(x));
      return _item ? _item : {};
    };

    _param={};
    location.search.slice(1).split("&").forEach(v=>{
      key = v.split("=")[0];
      _param[key] = v.replace(key+"=","");
    });
    var _cognito = {
      storage : {
        setTokens : _tokens => {
          localStorage.setItem("tokens", JSON.stringify(_tokens));
          _cognito.storage.jwt = _tokens.access_token;
          return _resolve(_tokens);
        },
        jwt : _getStorageItem("tokens").access_token
      },
      api : {
        getTokens : _code => {
          return $.ajax({
            url: opt.aws_cognito_tokenEndpoint,
            method: "POST",
            contentType: "application/x-www-form-urlencoded",
            data: {
              grant_type: "authorization_code",
              client_id: opt.aws_cognito_clientid,
              redirect_uri: opt.aws_cognito_redirecturl,
              code: _code,
            },
          })
        },
        getUser : () => {
          return $.ajax({
            url: opt.aws_cognito_userEndpoint,
            method: "GET",
            contentType: "application/json",
            headers: {
              Authorization: "Bearer "+_cognito.storage.jwt
            },
          })
        },
        graphql : (_query) => {
          return $.ajax({
            url: opt.aws_appsync_graphqlEndpoint,
            method: "POST",
            contentType: "application/json",
            headers: {
              Authorization: _cognito.storage.jwt
            },
            data: JSON.stringify(_query),
          })
        },
      },
    };

    //ログイン処理
    typeof _cognito.storage.jwt === "undefined" && !_param.code ? location.href = opt.aws_cognito_loginEndpoint : ""; //token, codeなし
    (_cognito.storage.jwt
      ? _resolve(_cognito.storage.jwt)
      : _cognito.api.getTokens(_param.code) //token取得
        .then(x=>_cognito.storage.setTokens(x)) //storageに保存
        .then(x=>location.href=opt.aws_cognito_redirecturl)
    )
    .then(() =>
      _cognito.api.graphql({
        query: `
          {
            getMyInfo {
              id
              email
            }
          }
        `})
    )
    .then(x=>{
      return x.data.getMyInfo
      ? _resolve(console.log(x.data.getMyInfo))
      : _cognito.api.getUser() //ユーザ情報取得
        .then(x=>_cognito.api.graphql({ //ユーザ作成
          operationName: "myInfo",
          query: `
            mutation myInfo($input: CreateMyInfoInput!){
              createMyInfo(input: $input){
                email
              }
            }`,
          variables: {
            "input": {
              "email": x.email
            }
          }
        }));
    })
    .fail(x=>_cognito.storage.setTokens({}).then(x=>location.href = opt.aws_cognito_loginEndpoint));
  </script>
</head>
<body>
</body>
</html>
```

### 外部javascriptの読み込み
・[CDN](https://code.jquery.com/)から取得したjQuery
・[amplify](https://aws-amplify.github.io/docs/js/start)コマンドで作成した aws-exports.js
・[aws-sdk.js](https://github.com/aws/aws-sdk-js)
・定数定義などをした config.js（これだけイチから作成したjsファイル）
・[Paho Javascript Client](https://www.eclipse.org/paho/clients/js/)からダウンロードした mqttws31.min.js

```javascript
  <!-- 外部javascriptの読み込み -->
  <script
  src="https://code.jquery.com/jquery-3.3.1.min.js"
  integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
  crossorigin="anonymous"></script>
  <script type="module" src="src/aws-exports.js"></script>
  <script src="src/aws-sdk.js"></script>
  <script src="src/config.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.min.js" type="text/javascript"></script>
```

### 独自関数群
・Promise用の _resolve, _reject関数
・ローカルストレージアクセス用の _getStorageItem関数
・Getパラメータの取得結果を格納する _paramオブジェクト

```javascript
var _resolve = function(x){ return $.Deferred().resolve(x).promise(); };
var _reject = function(x){ return $.Deferred().reject(x).promise(); };
var _getStorageItem = function(x){
  var _item = JSON.parse(localStorage.getItem(x));
  return _item ? _item : {};
};

_param={};
location.search.slice(1).split("&").forEach(v=>{
  key = v.split("=")[0];
  _param[key] = v.replace(key+"=","");
});
```

・Cognito/GraphQL周りのアクセス用の _cognitoオブジェクト

|関数名|説明|
|:---------------------------|:----------------------------|
|_cognito.storage.setTokens  |cognitoから取得したアクセストークンをローカルストレージに保存する関数|
|_cognito.storage.jwt        |アクセストークンを格納する変数|
|_cognito.api.getTokens      |cognitoからアクセストークンを取得する関数|
|_cognito.api.getUser        |cognitoからログイン中のユーザ情報を取得する関数|
|_cognito.api.graphql        |AppsyncのGraphQL APIを呼び出す関数|

```javascript
var _cognito = {
  storage : {
    setTokens : _tokens => {
      localStorage.setItem("tokens", JSON.stringify(_tokens));
      _cognito.storage.jwt = _tokens.access_token;
      return _resolve(_tokens);
    },
    jwt : _getStorageItem("tokens").access_token
  },
  api : {
    getTokens : _code => {
      return $.ajax({
        url: opt.aws_cognito_tokenEndpoint,
        method: "POST",
        contentType: "application/x-www-form-urlencoded",
        data: {
          grant_type: "authorization_code",
          client_id: opt.aws_cognito_clientid,
          redirect_uri: opt.aws_cognito_redirecturl,
          code: _code,
        },
      })
    },
    getUser : () => {
      return $.ajax({
        url: opt.aws_cognito_userEndpoint,
        method: "GET",
        contentType: "application/json",
        headers: {
          Authorization: "Bearer "+_cognito.storage.jwt
        },
      })
    },
    graphql : (_query) => {
      return $.ajax({
        url: opt.aws_appsync_graphqlEndpoint,
        method: "POST",
        contentType: "application/json",
        headers: {
          Authorization: _cognito.storage.jwt
        },
        data: JSON.stringify(_query),
      })
    },
  },
};
```
### ログイン処理
・jwtがストレージに保存されていない、かつ、パラメータにcodeが無い場合は、認証用ページに遷移させる

```javascript
//ログイン処理
typeof _cognito.storage.jwt === "undefined" && !_param.code ? location.href = opt.aws_cognito_loginEndpoint : ""; //token, codeなし
```

1.jwtがローカルストレージにある場合は2に進み、jwtが無い場合はcognitoから取得してローカルストレージに保存
2.jwtを取得したら、GraphQL（DynamoDB）から自身の情報を取得する
3.自身の情報が取得できたらコンソールに出力して終了する
4.自身の情報が取得できなかった場合は、DynamoDBにデータを作成する（cognitoからemailの情報を取得し、GraphQLで実行する）

```javascript
(_cognito.storage.jwt
  ? _resolve(_cognito.storage.jwt)
  : _cognito.api.getTokens(_param.code) //1.cognitoからtoken取得
    .then(x=>_cognito.storage.setTokens(x)) //1.storageに保存
    .then(x=>location.href=opt.aws_cognito_redirecturl)
)
.then(() =>
  _cognito.api.graphql({ //2.自身の情報を取得する
    query: `
      {
        getMyInfo {
          id
          email
        }
      }
    `})
)
.then(x=>{
  return x.data.getMyInfo
  ? _resolve(console.log(x.data.getMyInfo)) //3. コンソールに出力して終了
  : _cognito.api.getUser() //4. cognitoからユーザ情報取得
    .then(x=>_cognito.api.graphql({ //4. GraphQLでユーザ作成
      operationName: "myInfo",
      query: `
        mutation myInfo($input: CreateMyInfoInput!){
          createMyInfo(input: $input){
            email
          }
        }`,
      variables: {
        "input": {
          "email": x.email //4. cognitoから取得したユーザ情報のemailを利用する
        }
      }
    }));
})
.fail(x=>_cognito.storage.setTokens({}).then(x=>location.href = opt.aws_cognito_loginEndpoint)); //エラー時はtokenを全て消してリダイレクトする（最初からやり直し）
```

getMyInfoのResolverは以下の通り
IDにcognitoのsubを使う

```
#request mapping template
{
    "version": "2017-02-28",
    "operation": "GetItem",
    "key": {
        "id": { "S" : "${context.identity.sub}" }
    }
}
#response mapping template
$util.toJson($ctx.result)
```

createMyInfoのResolverは以下の通り
IDにcognitoのsubを使う
attributeValuesでinputのemailが設定される

```
#request mapping template
{
  "version": "2017-02-28",
  "operation": "PutItem",
  "key": {
    "id": $util.dynamodb.toDynamoDBJson($ctx.identity.sub),
  },
  "attributeValues": $util.dynamodb.toMapValuesJson($ctx.args.input),
  "condition": {
    "expression": "attribute_not_exists(#id)",
    "expressionNames": {
      "#id": "id",
    },
  },
}
#response mapping template
$util.toJson($ctx.result)
```

これで、ログインまでを実装することができた！

### APIの呼び出しについてもう少し詳しく解説
#### _cognito.api.getTokens
cognitoからアクセストークンを取得する関数
・urlは「https://<font color="Red">*設定したドメイン*</font>.auth.<font color="Red">*リージョン*</font>.amazoncognito.com/oauth2/token」を設定する
・client_idはユーザープールで設定した「アプリクライアントID」を設定する
・redirect_uriはアプリクライアントの設定の「コールバックURL」と一致させる
・codeは認証用ページからリダイレクトされた時に付与されるGetパラメータcodeが設定される

```javascript
getTokens : _code => {
  return $.ajax({
    url: opt.aws_cognito_tokenEndpoint,
    method: "POST",
    contentType: "application/x-www-form-urlencoded",
    data: {
      grant_type: "authorization_code",
      client_id: opt.aws_cognito_clientid,
      redirect_uri: opt.aws_cognito_redirecturl,
      code: _code,
    },
  })
}
```

#### _cognito.api.getUser
cognitoからログイン中のユーザ情報を取得する関数
・urlは「https://<font color="Red">*設定したドメイン*</font>.auth.<font color="Red">*リージョン*</font>.amazoncognito.com/oauth2/userInfo」を設定する
・Authorizationヘッダに、"Bearer " + jwt を設定する

```javascript
getUser : () => {
  return $.ajax({
    url: opt.aws_cognito_userEndpoint,
    method: "GET",
    contentType: "application/json",
    headers: {
      Authorization: "Bearer "+_cognito.storage.jwt
    },
  })
}
```

#### _cognito.api.graphql
AppsyncのGraphQL APIを呼び出す関数
・urlはAppsyncの「Settings」にあるAPI URLを設定する
・Authorizationヘッダに、jwt を設定する

```javascript
graphql : (_query) => {
  return $.ajax({
    url: opt.aws_appsync_graphqlEndpoint,
    method: "POST",
    contentType: "application/json",
    headers: {
      Authorization: _cognito.storage.jwt
    },
    data: JSON.stringify(_query),
  })
}
```

## WebサイトからAppsyncのSubscriptionを試す
以下の順序でSubscriptionを確認する
1.GraphQLのsubscriptionを実行する
2.戻り値でMQTT接続情報が返って来るため、Paho.MQTT.Clientを作成し、接続する
3.接続後、subscribeをしてメッセージを受け付ける
4.メッセージを受け取ると、コンソールにログを出力する

```javascript
var onConnect = function(x){ //3.接続後、subscribeをしてメッセージを受け付ける関数
  console.log("onConnect!");
  console.log(x);
  client.subscribe(sub.topics);
};
var onMessageArrived = function(x){ //4.メッセージを受け取ると、コンソールにログを出力する関数
  console.log("onMessageArrived!");
  console.log(x.payloadString);
};
var onConnectionLost = function(x){
  console.log("onConnectionLost!");
  console.log(x);
};
//subscription開始
_cognito.api.graphql({ //1.GraphQLのsubscriptionを実行する
  query: `
    subscription NewPostSub {
      onCreatePost {
        id
        postDate
        message
      }
    }
  `
})
.then(x=>{ //2.戻り値でMQTT接続情報が返って来るため、Paho.MQTT.Clientを作成し、接続する
  sub = {
    wssUrl: x.extensions.subscription.mqttConnections[0].url,
    client: x.extensions.subscription.mqttConnections[0].client,
    topics: x.extensions.subscription.mqttConnections[0].topics[0],
  }
  client = new Paho.MQTT.Client(sub.wssUrl, sub.client);
  var connectOptions = {
      onSuccess: onConnect, //3.接続後、subscribeをしてメッセージを受け付ける関数の登録
      useSSL: true,
      timeout: 3,
      mqttVersion: 4,
      onFailure: function(x) {
          console.log("connect failed!");
          console.log(x);
      }
  };
  client.onMessageArrived = onMessageArrived; //4.メッセージを受け取ると、コンソールにログを出力する関数の登録
  client.onConnectionLost = onConnectionLost;
  client.connect(connectOptions);
})
.fail(x=>console.log(x));

```

上記を実行後、onCreatePostの発火を促すcreatePostを実行すると、メッセージが出力される！
AppsyncのQueryでmutationを実行
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/55944697-0a60-60ad-ebfb-2c6da18acd5f.png)
メッセージが通知される
![image.png](https://qiita-image-store.s3.amazonaws.com/0/217144/3e4aa020-7592-d841-d002-a5d08d87fc1e.png)


# 終わりに
ご覧いただきありがとうございました。
日本語の情報がかなり少なく大変でしたが、シンプルなjavascriptでCognitoへのログイン、
Appsyncを利用したGraphQLの実行（Query／Mutation／Subscription）ができました。
次はルームを作って特定ユーザのみSubscriptionできるようにする仕組みや、オフライン時の動作などを確認したいと思います。

至らぬ点などありましたら、コメントいただけるとありがたいです。