AlexaのPersistent AttributesをAppSyncからGraphQLで取得する
R3の山内さんにAppSync&Lambdaでの使い方を教えて頂けたので、そこにAlexaを乗せてみようと思います。ということでAlexa skill Advent Calendar 2018 20日目 & […]
目次
R3の山内さんにAppSync&Lambdaでの使い方を教えて頂けたので、そこにAlexaを乗せてみようと思います。ということでAlexa skill Advent Calendar 2018 20日目 & Alexa Skills Kit SDK Advent Calendar 2018 20日目です。
今回使うデータベース
ちょうどre:invent2018のワークショップで使ったものがありましたので、これを使います。データ構造はこんな感じです。
{
  "Item": {
    "attributes": {
      "M": {
        "hintsUsed": {
          "N": "2"
        },
        "hintsPurchased": {
          "N": "10"
        }
      }
    },
    "id": {
      "S": "amzn1.ask.account.XXXXXXXX"
    }
  }
}
これをGraphQL(AppSync)とってきましょう。
AppSyncでAPIを作る

DynamoDBがすでにあるので、これをインポートして作ります。

リージョンとテーブル・ロールの指定がありますので選びましょう。

モデル定義では、attributesを追加してやりましょう。Map形式なので、JSON Objectで良さそうです。
作成すると、以下のようにlist~でデータが取れるようになります。

しかしQueryVariablesを見ると、「attributesの値が想定したものになっていない」ということに気づきます。次はここを修正します。
ModelをAlexaに合わせる
このままだとちょっとAlexaのバックエンドにするには厳しい点がありますので、手を入れていきましょう。初回は手で組んで、AWS CLI + CloudFormation でコード化してしまえば良いでしょう。
Schemaの修正
[Schema]からモデルを修正しましょう。今回は以下のように各値を修正しました。Attributesのモデル定義とcreate時のid入力必須化を実行しています。
type Attributes {
	hintsPurchased: Int
	hintsUsed: Int
}
input CreateNameTheShowInput {
	id: String!
	attributes: InputAttributes
}
input DeleteNameTheShowInput {
	id: String!
}
input InputAttributes {
	hintsPurchased: Int
	hintsUsed: Int
}
type Mutation {
	createNameTheShow(input: CreateNameTheShowInput!): NameTheShow
	updateNameTheShow(input: UpdateNameTheShowInput!): NameTheShow
	deleteNameTheShow(input: DeleteNameTheShowInput!): NameTheShow
}
type NameTheShow {
	id: String!
	attributes: Attributes
}
type NameTheShowConnection {
	items: [NameTheShow]
	nextToken: String
}
type Query {
	getNameTheShow(id: String!): NameTheShow
	listNameTheShows(filter: TableNameTheShowFilterInput, limit: Int, nextToken: String): NameTheShowConnection
}
type Subscription {
	onCreateNameTheShow(id: String, attributes: InputAttributes): NameTheShow
		@aws_subscribe(mutations: ["createNameTheShow"])
	onUpdateNameTheShow(id: String, attributes: InputAttributes): NameTheShow
		@aws_subscribe(mutations: ["updateNameTheShow"])
	onDeleteNameTheShow(id: String, attributes: InputAttributes): NameTheShow
		@aws_subscribe(mutations: ["deleteNameTheShow"])
}
input TableBooleanFilterInput {
	ne: Boolean
	eq: Boolean
}
input TableFloatFilterInput {
	ne: Float
	eq: Float
	le: Float
	lt: Float
	ge: Float
	gt: Float
	contains: Float
	notContains: Float
	between: [Float]
}
input TableIDFilterInput {
	ne: String
	eq: String
	le: String
	lt: String
	ge: String
	gt: String
	contains: String
	notContains: String
	between: [String]
	beginsWith: String
}
input TableIntFilterInput {
	ne: Int
	eq: Int
	le: Int
	lt: Int
	ge: Int
	gt: Int
	contains: Int
	notContains: Int
	between: [Int]
}
input TableNameTheShowFilterInput {
	id: TableStringFilterInput
}
input TableStringFilterInput {
	ne: String
	eq: String
	le: String
	lt: String
	ge: String
	gt: String
	contains: String
	notContains: String
	between: [String]
	beginsWith: String
}
input UpdateNameTheShowInput {
	id: String!
	attributes: InputAttributes
}
最後に[Save Schema] して保存しましょう。
Resolverの変更
createについては、デフォルトだとidを自動生成します。しかしAlexaの場合自動生成されると困るので、Resolverのテンプレートを以下のように変更します。
{
  "version": "2017-02-28",
  "operation": "PutItem",
  "key": {
    "id": $util.dynamodb.toDynamoDBJson($ctx.args.input.id),
  },
  "attributeValues": $util.dynamodb.toMapValuesJson($ctx.args.input),
  "condition": {
    "expression": "attribute_not_exists(#id)",
    "expressionNames": {
      "#id": "id",
    },
  },
}
これでOKです。[Queries]タブから意図したように動くか確認しましょう。
コンソールから動作確認
Lambdaから叩く前にSchemaがうまくいっているか確認します。
新規レコードの作成
まず[Query Variables]に以下の値をいれましょう。
{
  "createnametheshowinput": {
    "id": "amzn1.ask.account.EXAMPLE",
    "attributes": {
      "hintsUsed": 2
    }
  }
}
続いて以下のようなmutationを用意し、実行します。
mutation createNameTheShow($createnametheshowinput: CreateNameTheShowInput!) {
  createNameTheShow(input: $createnametheshowinput) {
    id
    attributes {
      hintsUsed
      hintsPurchased
    }
  }
}
以下のようなレスポンスが返ってくればOKです。
{
  "data": {
    "createNameTheShow": {
      "id": "amzn1.ask.account.EXAMPLE",
      "attributes": {
        "hintsUsed": 3,
        "hintsPurchased": null
      }
    }
  }
}
AWS CLIなどでDynamoDBからもデータが取れることを確認しておきましょう。
$ aws dynamodb get-item --table-name NameTheShow --key '{"id": {"S": "amzn1.ask.account.EXAMPLE"}}' | jq .
{
  "Item": {
    "attributes": {
      "M": {
        "hintsUsed": {
          "N": "3"
        }
      }
    },
    "id": {
      "S": "amzn1.ask.account.EXAMPLE"
    }
  }
}
レコードの更新
続いてレコードの更新をやってみましょう。
// mutation
mutation updateNameTheShow($createnametheshowinput: UpdateNameTheShowInput!) {
  updateNameTheShow(input: $createnametheshowinput) {
    id
    attributes {
      hintsUsed
      hintsPurchased
    }
  }
}
// Query Variables
{
  "createnametheshowinput": {
    "id": "amzn1.ask.account.EXAMPLE",
    "attributes": {
      "hintsUsed": 4,
      "hintsPurchased": 1
    }
  }
}
// Result
{
  "data": {
    "updateNameTheShow": {
      "id": "amzn1.ask.account.EXAMPLE",
      "attributes": {
        "hintsUsed": 4,
        "hintsPurchased": 1
      }
    }
  }
}
更新できましたね。
List / Getする
最後に取得もやっておきましょう。何気にListできるのありがたいですね。
List
query listNameTheShows {
  listNameTheShows {
    items {
      id
      attributes {
        hintsPurchased
        hintsUsed
      }
    }
  }
}
// Response
{
  "data": {
    "listNameTheShows": {
      "items": [
        {
          "id": "amzn1.ask.account.XXXXXXXXx",
          "attributes": {
            "hintsPurchased": 10,
            "hintsUsed": 2
          }
        },
        {
          "id": "amzn1.ask.account.EXAMPLE",
          "attributes": {
            "hintsPurchased": 1,
            "hintsUsed": 4
          }
        }
      ]
    }
  }
}
Get
// query
query getNameTheShow($getItem: String!) {
  getNameTheShow(id: $getItem) {
    id
    attributes {
      hintsUsed
      hintsPurchased
    }
  }
}
// Query Variables
{
  "getItem": "amzn1.ask.account.EXAMPLE"
}
// Result
{
  "data": {
    "getNameTheShow": {
      "id": "amzn1.ask.account.EXAMPLE",
      "attributes": {
        "hintsUsed": 4,
        "hintsPurchased": 1
      }
    }
  }
}
Node.jsから実行する
つぎはNode.jsからアクセスしてみましょう。最低限必要なのが以下の3つです。
$ yarn add -D isomorphic-fetch graphql-tag aws-appsync
$ vim .envrc
export AWS_REGION=REGION
export API_KEY=YOUR_APPSYNC_API_KEY
export API_URL=https://YOUR_API_URL.appsync-api.us-east-1.amazonaws.com/graphql
$ direnv allow
queryのコードは以下のようになります。
require('isomorphic-fetch');
// Require AppSync module
const gql = require('graphql-tag');
const AUTH_TYPE = require('aws-appsync/lib/link/auth-link').AUTH_TYPE;
const AWSAppSyncClient = require('aws-appsync').default;
// Secrets
const APIKey = process.env.API_KEY
const REGION = process.env.AWS_REGION
const URL = process.env.API_URL
// Import gql helper and craft a GraphQL query
const queryName1 = 'listNameTheShows'
const query1 = gql(`
query ${queryName1} {
  ${queryName1} {
    items {
      id
      attributes {
        hintsPurchased
        hintsUsed
      }
    }
  }
}`);
const queryName2 = 'getNameTheShow'
const query2 = gql(`
query ${queryName2}($id: String!) {
  ${queryName2}(id: $id) {
    id
    attributes {
      hintsPurchased
      hintsUsed
    }
  }
}`);
const variables2 = {
  id: "amzn1.ask.account.EXAMPLE"
}
// Set up Apollo client
const client = new AWSAppSyncClient({
    url: URL,
    region: REGION,
    auth: {
      type: AUTH_TYPE.API_KEY,
      apiKey: APIKey
    },
    disableOffline: true
});
// List ALL
const queryListItems = async (queryName, query) => {
  const params = {query}
  const { data } = await client.query(params)
  const { items } = data[queryName]
  console.log(`====${queryName}====`)
  items.forEach(item => console.log(item))
};
// Get
const queryGetItem = async (queryName, query, variables = {}) => {
  const params = {query}
  if (Object.keys(variables).length > 0) params.variables = variables
  const { data } = await client.query(params)
  const item = data[queryName]
  console.log(`====${queryName}====`)
  console.log(item)
};
queryListItems(queryName1, query1)
  .then(() => queryGetItem(queryName2, query2, variables2))
  .catch(e => console.log(e))
これをローカルで実行すると、このようになります。
====listNameTheShows====
{ id: 'amzn1.ask.account.XXXXXXXXXXXX',
  attributes: { hintsPurchased: 10, hintsUsed: 2, __typename: 'Attributes' },
  __typename: 'NameTheShow' }
{ id: 'amzn1.ask.account.EXAMPLE',
  attributes: { hintsPurchased: 1, hintsUsed: 4, __typename: 'Attributes' },
  __typename: 'NameTheShow' }
====getNameTheShow====
{ id: 'amzn1.ask.account.EXAMPLE',
  attributes: { hintsPurchased: 1, hintsUsed: 4, __typename: 'Attributes' },
  __typename: 'NameTheShow' }
ちゃんととれてますね。mutationはこうなります。
require('isomorphic-fetch');
// Require AppSync module
const gql = require('graphql-tag');
const AUTH_TYPE = require('aws-appsync/lib/link/auth-link').AUTH_TYPE;
const AWSAppSyncClient = require('aws-appsync').default;
// Secrets
const APIKey = process.env.API_KEY
const REGION = process.env.AWS_REGION
const URL = process.env.API_URL
// Import gql helper and craft a GraphQL query
const mutationName1 = 'createNameTheShow'
const mutationItem1 = gql(`
mutation createNameTheShow($createnametheshowinput: CreateNameTheShowInput!) {
  createNameTheShow(input: $createnametheshowinput) {
    id
    attributes {
      hintsUsed
      hintsPurchased
    }
  }
}
`);
const variables1 = {
  "createnametheshowinput": {
    "id": "amzn1.ask.account.EXAMPLE1",
    "attributes": {
      "hintsPurchased": 10
    }
  }
}
const mutationName2 = 'updateNameTheShow'
const mutationItem2 = gql(`
mutation updateNameTheShow($createnametheshowinput: UpdateNameTheShowInput!) {
  updateNameTheShow(input: $createnametheshowinput) {
    id
    attributes {
      hintsUsed
      hintsPurchased
    }
  }
}`);
const variables2 = {
  "createnametheshowinput": {
    "id": "amzn1.ask.account.EXAMPLE",
    "attributes": {
      "hintsUsed": 10,
      "hintsPurchased": 11
    }
  }
}
// Set up Apollo client
const appSyncClient = new AWSAppSyncClient({
    url: URL,
    region: REGION,
    auth: {
      type: AUTH_TYPE.API_KEY,
      apiKey: APIKey
    },
    disableOffline: true
});
const mutateItem = async (name, item, variables) => {
  const params = {
    mutation: item,
    variables
  }
  const { data } = await appSyncClient.mutate(params)
  console.log(`====${name}====`)
  console.log(data[name])
};
mutateItem(mutationName1, mutationItem1, variables1)
  .then(() => mutateItem(mutationName2, mutationItem2, variables2))
  .catch(e => console.log(e))
1がcreateで2がupdateになっています。createはupdate / put的用途で使うとalready existsでエラーでるのでupdateを使いましょう。
====createNameTheShow====
{ id: 'amzn1.ask.account.EXAMPLE1',
  attributes: { hintsUsed: null, hintsPurchased: 10, __typename: 'Attributes' },
  __typename: 'NameTheShow' }
====updateNameTheShow====
{ id: 'amzn1.ask.account.EXAMPLE',
  attributes: { hintsUsed: 10, hintsPurchased: 11, __typename: 'Attributes' },
  __typename: 'NameTheShow' }
ちなみにsubscriptionをする際の注意点がドキュメントにありますので、こちらもご覧くださいませ。
おわりに
あとはこれをLambdaに組み込むことで、AlexaのPersistant AttributesをAppSyncから扱えるようになります。使いどころとしては、こういうところがあるでしょう。
- Alexaでプレイしたゲームの結果をweb / アプリからリアルタイムで確認する
 - Alexaの利用状況をリアルタイムでモニタリング
 - サイネージなどと組み合わせた受付スキル
 - etc
 
手札としてもっているだけでも、発想の幅が広がると思います。