20190512のNode.jsに関する記事は12件です。

puppeteerを体験してみた

puppeteer とは、GUIを操作することなく、プログラムからAPIでブラウザ(Chrome)を制御できる Node.js で作られたライブラリです。
https://webbibouroku.com/Blog/Article/puppeteer

何ができるのか
- 自動でchromeを立ち上げて検索し、要素をとってくる
- 検索結果のスクショをとったり
- webサイトにログインしたり
- 自動テストを走らせてslackに結果を表示したりもできる

1. node.jsのインストール

参考にしたサイト
https://qiita.com/kyosuke5_20/items/c5f68fc9d89b84c0df09

【手順】
- Homebrewを使用してnodebrewをインストール

$ brew install nodebrew

  • パスを通す

$ echo 'export PATH=$HOME/.nodebrew/current/bin:$PATH' >> ~/.bash_profile

  • ターミナルを再起動

  • ディレクトリを作成して
    $ mkdir -p ~/.nodebrew/src

-最新のバージョンをインストール
$ nodebrew install-binary latest

-バージョンの有効化
$ nodebrew use v7.1.0

バージョンの確認で以下のように表示されたらOK

$node -v
>v12.2.0

2.puppeteerのインストール

参考にしたサイト
https://www.wakuwakubank.com/posts/620-javascript-puppeteer/

$ npm install --save puppeteer

3. 使ってみる

sample.jsを作成

sample.js
const puppeteer = require('puppeteer');

(async () => {
    const browser = await puppeteer.launch({
        headless: false,  // ブラウザが動く様子を確認する
        slowMo: 300  // 動作確認しやすいようにpuppeteerの操作を遅延させる
    })
    const page = await browser.newPage()

    //chromeを開く
    await page.goto('https://www.google.com/')
    // 検索窓に「こんにちは」と入力
    await page.type('input[name=q]', 'こんにちは', { delay: 50 }) 
    //検索
    await page.click('input[type="submit"]')
    //スクリーンショット
    await page.screenshot({ path: 'screenshot/sample1.png' })

    await browser.close()
})()

上記ファイルが置いてあるディレクトリに移動して、
スクリーンショットを保存するフォルダを作成
$ mkdir screenshot

いざ、実行
$node sample.js

自動でchromeがたちあがり、検索してくれる
スクリーンショット 2019-05-12 12.31.12.png

スクリーンショットが保存されている
スクリーンショット 2019-05-12 12.31.33.png

自動でtwitterにログインしてみる

以下のサンプルコードを参考にしました
https://github.com/checkly/puppeteer-examples

環境変数でユーザー名とパスワードを設定

config.json
{
"TWITTER_USER": "ユーザー名",
"TWITTER_PWD": "パスワード"
}

gitにpushする際は上記ファイルを除外します

twitter.js
const puppeteer = require('puppeteer');
const {TWITTER_USER, TWITTER_PWD} = require('./config.json');

(async () => {
    const browser = await puppeteer.launch({
        headless: false,
        slowMo: 300
    })
    const page = await browser.newPage()
    await page.setViewport({ width: 1280, height: 800 })

    const navigationPromise = page.waitForNavigation()

    await page.goto('https://twitter.com/')
    await page.waitForSelector('.StaticLoggedOutHomePage-cell > .StaticLoggedOutHomePage-login > .LoginForm > .LoginForm-username > .text-input')
    await page.type('.StaticLoggedOutHomePage-cell > .StaticLoggedOutHomePage-login > .LoginForm > .LoginForm-username > .text-input', TWITTER_USER)

    await page.type('.StaticLoggedOutHomePage-cell > .StaticLoggedOutHomePage-login > .LoginForm > .LoginForm-password > .text-input', TWITTER_PWD)
    await page.click('.StaticLoggedOutHomePage-content > .StaticLoggedOutHomePage-cell > .StaticLoggedOutHomePage-login > .LoginForm > .EdgeButton')
    await navigationPromise

    await page.waitForSelector('#timeline')
    await page.screenshot({ path: 'screenshot/sample2.png' })
    await browser.close()
})()

ターミナルで実行
$node sample2.js

前回同様、自動でchromeが立ち上がり、twiiterを検索して、ユーザー名とパスワードを入力してくれます。
スクリーンショット 2019-05-12 23.12.10.png

タイムラインのスクリーンショットを撮ってくれました。
スクリーンショット 2019-05-12 23.13.08.png

感想

勝手に動いている様子を見ていると、なんだかすごいことをしているようで、楽しい。
もっと実用的なのを考えてみたい。進捗があれば追記します。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Node.jsで作ったスクレイピングアプリをDockerコンテナで起動してみた

やりたかったこと

  • サーバーレス環境でスクレイピングアプリを動かしたかった
  • Knative試したかったのでコンテナ化する必要があった

nodejsでスクレイピングするアプリをサクッと作る

まずは好きなところにディレクトリ切って下記コマンドを叩く
npm init
出てくる質問は全てEnter連打した
次にスクレイピングに必要な selenium-webdriver をインストール
https://www.npmjs.com/package/selenium-webdriver

npm install selenium-webdriver

scrapingするプログラムを書いていきます。今回はgoogle chromeが既にインストールされている前提で話を進めます。

vim example.js

example.js
const { Builder, By, Capabilities } = require('selenium-webdriver');

const capabilities = Capabilities.chrome();
capabilities.set('chromeOptions', {
  args: [
    '--headless',
    '--no-sandbox',
    '--disable-gpu',
    '--window-size=1980,1200',
    // other chrome options
  ],
});

(async function example() {
  console.log('start google scraping');

  const driver = await new Builder().forBrowser('chrome').withCapabilities(capabilities).build();
  try {
    await driver.get('https://www.google.com/?hl=');

    const text = await driver.findElement(By.xpath('/html')).getText();
    console.log(text);
  } finally {
    await driver.quit();
    console.log('finish scraping');
  }
}());


const sleep = time => new Promise((resolve) => {
  setTimeout(() => {
    resolve();
  }, time);
});

puppeteerもサクッと立ち上げるのには良さそうだったけどブラウザがchromium縛りになるのが嫌だったので今回はWebdriver使いました

npm scriptsを定義する

package.json のscriptsに以下のようにstartタスクを追加します

package.json
"scripts": {
    "start": "node ./example.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

試しにスクレイピングを走らせてみます
npm start

consoleログ

fuga@hoge ~/D/g/scraping-sample> npm start

> scraping-sample@1.0.0 start /Users/a12711/Documents/git-localrepository/scraping-sample
> node ./example.js

start google scraping
Gmail
画像
ログイン
日本
プライバシー規約設定
広告ビジネスGoogleについて
finish scraping

うまくスクレイピングが動いてそうです

Dockerfileを作る

今回はサクッと作りたかったので、seleniumのstandalone-chromeのイメージを元にして、nodejsをインストールするDockerfileを作成しました

Dockerfile
FROM selenium/standalone-chrome:3.141.59 

WORKDIR /usr/src/app

COPY package*.json ./

USER root

RUN curl -SL https://deb.nodesource.com/setup_8.x | bash
RUN apt-get install -y nodejs

RUN npm install

COPY . .

CMD npm start

Docker Build

dockerが入っていない方はdocker desktop等でdocker入れてください

Dockerイメージを作成する
docker build -t sample/robot .

コンテナ起動
docker run sample/robot

consoleログ
fuga@hoge ~/D/g/scraping-sample> docker run sample/robot

> scraping-sample@1.0.0 start /usr/src/app
> node ./example.js

start google scraping
Gmail
画像
ログイン
日本
プライバシー規約設定
広告ビジネスGoogleについて
finish scraping

先程と同様のログが出ています
これでスクレイピングのアプリをDockerコンテナとして動かすことができました

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

fish: nodebrewで必要なPATHを設定

nodebrewfishの環境で使うためのパスの設定。

~> set -U fish_user_paths $HOME/.nodebrew/current/bin $fish_user_paths

コマンドを実行すると、自動的に~/.config/fish/fish_variablesに反映される。

参考:
https://fishshell.com/docs/current/tutorial.html#tut_path

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity+Node.jsで1対1の簡単なオンラインシューティングゲームを作った話 導入編

この記事に関わる記事一覧

導入編(現在の記事)
Node.js編(投稿予定)
Unity編(投稿予定)

はじめに

Node.jsを使ってみたく、実際に手を動かして何か作ってみるのが一番だと思い、
自分なりにプレイヤーのマッチングや同期の取り方を考えてやってみようと思いました。

Node.js(サーバー側)よりもUnity(クライアント側)で苦戦しました...

私はNode.js,リアルタイム通信の知識がそこまであるわけではないので、
素人なりにどう考えて実装していったかの記録を残していきたいと思い記事を書き始めました。

コードの書き方に正解はないと思うので、これから書いていく記事を通して
今回作成したゲームの作り方の概念だけ書いていこうかなと思います。

成果物

今回作成したアプリはAndroid,iosでリリースしました。
AppStore
PlayStore
Mac版
Windows版
Android版は実機で動きを確認できていないので、動かなかったら申し訳ございません。

動きを確認するためにはデバイスが二つ必要になります。(PCとスマホでも確認できます)
2019/5/11現在サーバー稼働中ですのでマッチング開始ボタンを押すと同時に押した人(5秒以内)とマッチングします。

操作方法

ドラッグ:移動
タップ :自機が向いている方向に弾を発射

スクリーンショット

IMG_5512.PNG

IMG_5514.PNG

通信プロトコル

今回はUDPを選択しました。
マルチ対戦で使える通信プロトコルは以下の3種類あると思います。

通信プロトコル 特徴
UDP 速い!!でも、ちゃんと届いたか、届く順番が保証されない
TCP UDPとは違い確実に相手に届ける。(UDPよりは遅い)
WebSocket 低コストのTCP?Webで使うソケット通信

WebSocketはよく分かっていないのですが、
Webブラウザで手軽に双方向通信ができる!!そんなイメージが私の中にあります。
詳しいことは調べていただければと思います...

今回作成するのは毎フレーム同期が必要なリアルタイムで動くシューティングゲームなのでUDPにしました。

同期の取り方

はじめの方は入力情報だけ同期すれば大丈夫だろうと考えていました。
しかし、実装していくうちに2つのクライアント間で状態が異なっていき
終いにはプレイヤーの位置が違う、勝敗結果が異なるということになってしまいました。

今回は自前で衝突応答、判定などを作るのが面倒くさかったので、Rigidbody2D使ってしまおうと
考えたのがよくありませんでした。

Unityの中身で物理の処理がどのタイミングで実行されているのかを把握するのが難しいので、
このくらいの簡単なゲームであれば、自分で衝突判定や衝突応答を書いてしまって実行されるタイミングを
管理できるようにした方が後ほど苦しまなくなると思います。

結果的には以下の3つで同期を取ることになりました。

  • 入力情報(バーチャルJoystic、発射)
  • 座標
  • 弾が当たったかどうかの判定

また今回は通信する情報をJson形式で扱いました。

位置と入力の同期

まず何も考えずにプレイヤーを動かしてと言われたら、
毎フレーム入力に応じてプレイヤーを動かす処理を書くと思います。

しかし、このように実装してしまっては同期は取れません。

プレイヤーAとBがいるとします。
プレイヤーAの端末では現在5フレーム目で、プレイヤーBの端末も現在5フレームであったとします。
しかし、プレイヤーAの端末からプレイヤーBの端末まで情報が届くのが同じ5フレーム内であるという保証はありません。
通信には遅延が付き物です。
また、端末によってフレームの更新速度も違うのでプレイヤーAの端末では5フレーム目でも、
プレイヤーBの端末は4フレーム目が実行中かもしれません。

自分の端末で自機を動かしていて、情報が到着次第相手機を動かすような実装では同期は取れません。
この状態で作っていって最後に同期が取れないと気づいた頃には後の祭りです。

詳しくはUnity編で書きたいと思うのでここではざっくりと書きます。

Queueというデータ構造を使って入力情報を毎フレーム入れていき、相手の入力情報が届き次第
Queueから自分の入力情報を取り出して相手の入力情報と一緒に実行します。

キューとは
オブジェクトをどんどん追加していけるデータ構造で
一番最初に追加した古いオブジェクトから順に取り出していきます。(先入れ先出し)

なので、入力した情報は数フレーム遅れて実行されることになります。
私は3フレーム以内に相手の情報が届かなかったら、自分の入力情報を追加することをやめて待つようにしました。
もし待たなければ、情報の到着が30フレーム遅れて到着した場合約0.5秒前の自分の操作が実行されてしまいます。

位置と入力の同期では以下のJSONで送り合いました。

{
    "type":"input",
    "own":"{\"id\":\"16aab79959a24f\",\"name\":\"Black\",\"port\":\"\",\"address\":\"\"}",
    "rival":"{\"id\":\"16aab79983f274\",\"name\":\"White\",\"port\":\"61374\",\"address\":\"***.***.**.**\"}",
    "requireNextFrame":12,
    "inputObjects":
    [
        "{\"frame\":13,\"axisX\":0.0,\"axisY\":0.0,\"isFire\":false,\"fireDirX\":0.0,\"fireDirY\":0.0,\"posX\":0.0,\"posY\":-5.0,\"rotZ\":0.0}",
        "{\"frame\":14,\"axisX\":0.0,\"axisY\":0.0,\"isFire\":false,\"fireDirX\":0.0,\"fireDirY\":0.0,\"posX\":0.0,\"posY\":-5.0,\"rotZ\":0.0}"
    ]
}
Key 内容
type このJSONは何の情報かを判別するためのもの(全てのJSONに付けた)
own 自分の情報のJSON文字列(UserObj)
rival 相手の情報のJSON文字列(UserObj)
requireNextFrame 現在何フレーム目からの相手の入力情報が欲しいか
inputObjects 位置情報・入力情報(配列)(InputObj)

C#でJSONをパースする都合で、UserObj,InputObjをJSON文字列にしています。

UserObj

Key 内容
id ユーザーの識別ID(使わなかった)
name ユーザー名
port ポート番号
address グローバルIPアドレス

自分(own)のアドレス、ポート番号は特に必要がなかったので入れませんでした。

InputObj

Key 内容
frame 何フレーム目の情報か
axisX バーチャルパッドのX軸(-1.0~1.0)
axisY バーチャルパッドのY軸(-1.0~1.0)
isFire 弾が発射されたか(false,true)
fireDirX 弾の発射方向(X方向)
fireDirY 弾の発射方向(Y方向)
posX 現在の位置X
posY 現在の位置Y
rotZ 現在の回転角度Z(degree)

InputObjを複数送る理由は、UDP通信だとパケットロストが発生して情報が届かない時があるからです。
例えば5フレーム目で送った入力情報が相手に届かず、6フレームの入力情報は相手に届いた場合でも、
6フレームの入力情報に5フレーム目の入力情報が含まれているので届けることができます。

弾が当たったかどうかの同期

位置・入力の同期が取れたことでプレイヤー同士の位置がずれることなくなって満足していました...

しかし、何回かプレイをしてみると2つの端末の間でHPの値が異なったのです。
多分以下の原因があると思います。

  • 計算の誤差があり弾が当たっていたり、当たらなかったり2つの端末で結果が異なった
  • 弾の位置の同期はしていなかったので物理演算の結果が異なっていた

本来弾の位置の同期もとったほうがいいとは思ったのですが、面倒くさかったので
ある弾がプレイヤーに当たった場合、JSONで相手に情報を送って
お互いの端末でその弾がプレイヤーに当たっていた場合ダメージ判定をするようにしました。

弾の当たった情報を以下のJSON形式で相手に送りました。

{
    "type":"hit-bullet",
    "bulletType":"BLUE",
    "fireFrame":152,
    "own":"{\"id\":\"16aabc7087860\",\"name\":\"Black\",\"port\":\"\",\"address\":\"\"}",
    "rival":"{\"id\":\"16aabc70728244\",\"name\":\"White\",\"port\":\"62167\",\"address\":\"***.***.**.**\"}"
}
Key 内容
type このJSONは何の情報かを判別するためのもの(全てのJSONに付けた)
bulletType 弾の色(BLUE,RED)
fireFrame 当たった弾が発射されたフレーム
own 自分の情報のJSON文字列(UserObj)
rival 相手の情報のJSON文字列(UserObj)

成果物で動きを確認していただければ分かると思うのですが、
お互いの端末で自機は赤で、相手機は青で表示されています。
なので、送るときはbulletTypeの色を逆にして送りました。

1フレームに打てる弾は1個なのでfireFrameはその弾固有のIDになります。

自分の端末で当たった弾の情報をListに追加していき、
相手から送られてきた弾の情報もListに追加していきます。
この二つのListを照らし合わせたときにbulletType,fireFrameが一致するものがあれば、
bulletTypeに応じて相手または自分へのダメージ処理を行うようにしました。

最後に

次回はNode.jsの実装について書いていきたいと思います。
ほとんど自分の備忘録みたいな感じで書いているので分かりにくいところが多かったと思います。
今回の記事を書き終えてみて、もっと情報を整理してプレイヤーの位置だけを同期するサンプルを作成して
ワークショップ形式で説明を書いたほうが分かりやすいかなと思いました。
このシリーズの記事を書き終えたらそっちの方も書いてみようかなと思います。

自分なりの方法でやっているので、間違っているところがあるとは思いますがご了承ください。
最後まで記事を読んでいただきありがとうございました!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

gyp ERR! stack Error: EACCES: permission denied, stat '/root/.npm-global/lib/node_modules/expo-cli/node_modules/iltorb/.node-gyp/10.15.3'

working

sudo npm install --unsafe-perm -g expo-cli

not working

sudo npm install -g expo-cli

preference

https://github.com/nodejs/node-gyp/issues/454#issuecomment-58792114

sudo npm install --unsafe-perm --verbose -g sails
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

配列を仮想DBとした掲示板の作成(5/5):DELETEリクエスト編

GitHub

https://github.com/atlansien/js_excercise_for_backend_7

前提条件

配列を仮想DBとした掲示板APIサーバの作成(1/5):はじめに・環境構築・DB、コメントモデルの作成

配列を仮想DBとした掲示板の作成(2/5):GETリクエスト・サーバ起動編

配列を仮想DBとした掲示板の作成(3/5):POSTリクエスト編

配列を仮想DBとした掲示板の作成(4/5):PUTリクエスト編

実装する機能

「DELETE/api/comment/:id」リクエストを送ると、id値に紐づいたComment1件を削除して、削除したComment1件がレスポンス値として返ってくる

この記事のまとめ

  • 指定したidと合致したcomment1件を削除して、そのコメントを返り値とするメソッドを作成した
  • DELETEリクエストを送った時のメソッドを作成した
  • メソッド名の変更を行なった
  • POSTMANでDELETEリクエスト時の動きを確認した

指定したid値と合致したcomment1件を削除して、削除したコメント1件を返すメソッドの作成(deleteComment()

deleteComment: ({ id }) => {
  if (typeof id !== 'number' || id < 1) {
    throw new Error(
      'idに適切でない値が入っています、1以上の数字を入れてください'
    );
  }

  const target = comments.findIndex(comment => id === comment.id);
  if (target === -1) {
    throw new Error('idと合致するCommentが見つかりません');
  }
  const deletedComment = comments.splice(target, 1)[0];

  return deletedComment;
},
  • id値に適切でないプロパティ値が入っている
  • id値と合致するcommentが見つからない

上記の場合はエラーを返し、適切な値が入っている場合のみメソッドが成功するように設計しました。

また、idのプロパティ値と配列のインデックス値は同一ではないので、配列内からid値と合致する要素を探す必要があります、そこでArray.prototype.findIndex()を使用します。

const target = comments.findIndex(comment => id === comment.id);

アロー関数の特徴である、文の戻り値を返すだけならブロックとreturnを省略した書き方をしています。
これで、id値と合致するコメントを配列内から探すことができます。

そして、コメントを一件削除して、そのコメントを返す必要があります。

const deletedComment = comments.splice(target, 1)[0];

return deletedComment;

Array.prototype.splice()は破壊的なメソッドです、第一引数に先ほど会得したtarget、第二引数で削除したい配列の数1を入れることで、id値と合致するコメント1件がcomments配列から削除されます。

ここで、splice()の末尾に[0]をつけてますが、これをつけることで、配列ではなく1件のデータとして要素を返すことができます。

詳しい説明はこちらで記事にしてます。
【Javascript】splice()の末尾に[0]をつけ取り除かれた要素1つをデータで返す - じんのアウトプット日記(だいたい毎日更新)

それでは、実際にうまく動作するかテストします。

deleteComment()のテスト

テスト内容

  1. Comment.deleteCommentはメソッドである
  2. idの引数に不正な値が入っていた場合、エラーが返る
  3. idの引数と合致するCommentがない場合、エラーが返る
  4. 適切なデータを送った場合、idと合致するComment1件が返ってくる
  5. 4.と同時に、idと合致するComment1件が配列から削除される。

この5点を確認します。

deleteComment.test.js
const assert = require('power-assert');
const Comment = require('../../models/Comment');

describe('Comment.deleteCommentのテスト', () => {
  // 以下にテストコードを記述していきます
});

1.Comment.deleteCommentはメソッドである

it('Comment.deleteCommentはメソッドである', () => {
  assert.strictEqual(typeof Comment.deleteComment, 'function');
});

typeof演算子で、Comment.deleteCommentがメソッドであるかどうかテストします。

2.idの引数に不正な値が入っていた場合、エラーが返る

it('idの引数に不正な値が入っていた場合、エラーが返る', () => {
  const invalidIdList = [
    { id: 0 },
    { id: -1 },
    { id: null },
    { id: {} },
    { id: [] },
    { id: '1' },
  ];

  invalidIdList.forEach(id => {
    try {
      Comment.deleteComment(id);
      assert.fail();
    } catch (error) {
      assert.strictEqual(
        error.message,
        'idに適切でない値が入っています、1以上の数字を入れてください'
      );
    }
  });
});

invalidIdList配列に思いつく限りの適切でない値を入れ、forEachで各値ごとにdeleteCommentを実行します。メソッドがエラーになるようであればcatchの方でエラーメッセージと比較します。 1つでも成功した場合はassert.fail()によりエラーで終了、失敗となります。

3.idの引数と合致するCommentがない場合、エラーが返る

it('idのプロパティ値と合致するCommentがない場合、エラーが返る', () => {
  const invalidId = { id: 999999999 };

  try {
    Comment.deleteComment(invalidId);
    assert.fail();
  } catch (error) {
    assert.strictEqual(error.message, 'idと合致するCommentが見つかりません');
  }
});

invalidIdに現在配列には入っていないid値を入れ、deleteCommentを実行します。メソッドがエラーになるようであればcatchの方でエラーメッセージと比較します。成功した場合はassert.fail()によりエラーで終了、失敗となります。

4.適切なデータを送った場合、idと合致するComment1件が返ってくる

  it('適切なid値を送った場合、idと合致するComment一件が返される', () => {
    const validId = { id: 1 };

    const deletedComment = Comment.deleteComment(validId);
    assert.deepEqual(deletedComment, {
      id: validId.id,
      username: deletedComment.username,
      body: deletedComment.body,
      createdAt: deletedComment.createdAt,
      updatedAt: deletedComment.updatedAt,
    });
  });

idに適切な値が入っていた場合は、deleteCommentが実行した時に、返ってくる要素の各プロパティ値には適切にデータが入っているかassert.deepEqualで確認します。

5.4.と同時に、idと合致するComment1件が配列から削除される。

it('適切なid値を送った場合、idと合致するComment一件が配列から削除される', () => {
  const oldComments = Comment.findAll();
  const validId = { id: 2 };

  Comment.deleteComment(validId);

  const currentComments = Comment.findAll();

  assert.equal(oldComments.length, currentComments.length + 1);
});

idに適切な値が入っていた場合は、deleteComment実行後、配列から要素が一件削除されているかテストします。

各テストが成功したら、次はAPIからDELETEリクエストをした時に、deleteCommentが実行するようにしていきます。

「DELETE/api/comment/:id」リクエストを送ると、id値と合致したcomment1件を削除して、削除したcomment1件がレスポンス値として返ってくるメソッドの作成(deleteComment())

comment.js(controller)
deleteComment: (req, res) => {
  try {
    const parsedId = parseInt(req.params.id, 10);

    const removeComment = Comment.removeComment({
      id: parsedId
    });

    res.status(200).json(removeComment);
  } catch (error) {
    res.status(400).json({ message: error.message });
  }
}
comment.js(router)
router
  .route('/:id')
  .put(controller.putComment)
  .delete(controller.deleteComment);

今回もPUTリクエストと同じくルートパラメータを使用します。ルートパラメータで得た値を数値に変換し、removeCommentのid値に代入します。
と、その前に突然現れたremoveCommentとはなんでしょうか?

メソッド名の変更

今回DELETEリクエスト時に実行されるdeleteCommentと、指定したid値と合致したComment1件を削除して、削除したコメント1件を返すメソッドにて、modelsで作成したdeleteCommentの名前が被ってしまいましたので、 modelsの方をremoveCommentと改名しました。

deleteComment()のテスト

test/app/apiディレクトリにdeleteComment.test.jsファイルを新たに作成し、そちらでテストを行なっていきます。

deleteComment.test.js
const assert = require('power-assert');
const requestHelper = require('../../../helper/requestHelper');

const getComments = async () => {
  const response = await requestHelper.request({
    method: 'get',
    endPoint: '/api/comments',
    statusCode: 200,
  });
  return response.body;
};

const deleteComment = async (id, code) => {
  const response = await requestHelper.request({
    method: 'delete',
    endPoint: `/api/comments/${id}`,
    statusCode: code,
  });
  return response;
};

describe('TEST 「DELETE api/comments/:id」', () => {
  // 以下にテストコードを記述していきます
});

テスト内容

  1. 適切でないidを送ると400エラーが返る
  2. 送られたidとひもづくCommentがない場合、400エラーが返る
  3. 適切なデータを送った場合、idとひもづくCommentがレスポンス値として返ってくる。
  4. 3.と同時に、配列内にあったidとひもづくCommentは配列から削除される。

この4点を確認するテストコードを記述していきます。

1.適切でないidを送ると400エラーが返る

it('適切でないid値を送るとエラーが返る', async () => {
  const response = await deleteComment(0, 400);

  assert.deepStrictEqual(response.body, {
    message: 'idに適切でない値が入っています、1以上の数字を入れてください',
  });
});

id値に0(適切でない数値)を入れると、ステータスコードは400、そしてエラーメッセージが返ってくることを確認します。

2.送られたidとひもづくCommentがない場合、400エラーが返る

it('idの値と合致するCommentがない場合エラーが返る', async () => {
  const response = await deleteComment(9999999, 400);

  assert.deepStrictEqual(response.body, {
    message: 'idと合致するCommentが見つかりません',
  });
});

現在配列に入っているcommentのidのどれにも合致しない数字をidに入れると、ステータスコードは400、そしてエラーメッセージが返ってくることを確認します。

3.適切なデータを送った場合、idとひもづくcomment1件がレスポンス値として返ってくる。
4.3.と同時に、配列内にあったidとひもづくcomment1件は配列commentsから削除される。

it('適切なid値を送ると、idと合致するCommentが返ってくる、また該当のCommentは配列内から削除される', async () => {
  const oldComments = await getComments();

  const validId = 4;
  const response = await deleteComment(validId, 200);
  const comment = response.body;

  assert.deepStrictEqual(comment, {
    id: validId,
    username: comment.username,
    body: comment.body,
    createdAt: comment.createdAt,
    updatedAt: comment.updatedAt,
  });

  const currentComments = await getComments();

  assert.strictEqual(oldComments.length, currentComments.length + 1);
});

id値と合致するcommentがあった場合、合致したcomment1件、ステータスコード200が適切に返ってきていることを確認します。
それと同時に、deleteComment実行前と実行後の配列を比較して、comment1件が削除されていることを確認します。

以上のテストが全て正常に終了したら、今回のAPIサーバを用いた掲示板の作成は全て終了です。

最終的な環境

.
├── Task_info.md
├── app.js
├── controllers
│   └── comments.js
├── helper
│   └── requestHelper.js
├── index.js
├── models
│   └── Comment.js
├── package-lock.json
├── package.json
├── readme.md
├── routers
│   └── comments.js
└── test
    ├── app
    │   └── api
    │       ├── deleteComment.test.js
    │       ├── getComment.test.js
    │       ├── postComment.test.js
    │       └── putComment.test.js
    ├── mocha.opts
    └── models
        ├── createComment.test.js
        ├── findAll.test.js
        ├── removeComment.test.js
        └── updateComment.test.js

POSTMANを使用してDELETEリクエスト時の動きをチェックする(gif)

DELETErequest.gif

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

webpack.config.ts を TypeScript で書く(2019初夏)

急いでいる方はこちら

追加パッケージ

Bash
// 前提
$ yarn add -D webpack webpack-cli typescript ts-loader
// 追加するパッケージ
$ yarn add -D ts-node @types/node @types/webpack

tsconfig.json の変更

module: "commonjs" とできる場合

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  }
}

module: "es~" しか許されない環境の場合

ts-nodecommonjs のモジュール構文しかサポートしていないため、webpack 用に別に tsconfig を用意する。
また、その tsconfig へのパス指定を可能とするパッケージを追加する。

Bash
$ yarn add -D tsconfig-paths cross-env
tsconfig.webpack.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "esModuleInterop": true
  }
}
package.json
"scripts": {
  "build": "cross-env TS_NODE_PROJECT=\"tsconfig.webpack.json\" webpack"
},

ビルド実行

webpack.config.ts
import path from 'path';
import webpack from 'webpack';

const config: webpack.Configuration = {
  mode: 'production',
  entry: './src/index.ts',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        loader: 'ts-loader',
      },
    ],
  },
};

export default config;

VSCode では補完が効きます!

src/index.ts
class Greeting {
  public greet = (): void => {
    console.log('Hello TypeScript + Webpack!');
  };
}

const hello = new Greeting();
hello.greet();
Bash
$ yarn webpack

// または

$ yarn build

// 実行
$ node build/bundle.js
Hello TypeScript + Webpack!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Alexa Reminder skillですべてのリマインダーの取得と削除

Alexa Reminder skillで「すべてのリマインダーの取得」と「リマインダーの削除」を試してみたので、その内容をまとめます。
「リマインダーの作成」についてはいろいろネットを検索するとでてきましたが、「すべてのリマインダーの取得」や「リマインダーの削除」などについての記事は見つけることができませんでしたので、ここにメモを残します。

前提

まず、「すべてのリマインダーの取得」にあたり、「リマインダーの作成」はできているものとします。
以下の記事で作成済みです。
Alexa Reminder skillのリマインダー作成時のtriggerについて
Alexa Reminder skillでリマインダー作成時にInvalid Token、DEVICE_NOT_SUPPORTED、UNAUTHORIZEDとなった場合の対策

注意事項

「すべてのリマインダーを取得」する場合、Alexaのリマインダーにスキルで作成したリマインダーが設定されていることが望ましいと思います。
今回、取得できるリマンダーは、自分で作成したスキル上で追加したものに限られます。
別のスキルやもともとAlexaに標準で搭載されているリマインダーは、取得できません。
そういったリマインダーはセッション外として別途アクセス許可が必要なようなため、試せていません。
そのやり方は、以下にあるようですが、私にはよくわかりませんでした。
AlexaリマインダーAPIのセッション内およびセッション外の動作

すべてのリマインダーの取得

すべてのリマインダーを取得する理由

  • リマインダーを削除するためには、削除するリマインダーのid、すなわちalertTokenが必要になります。
  • 削除可能なリマインダーは、アクティブなもの(statusがON)だけに限られます。完了済みのもの(statusがCOMPLETE)は削除できず、削除しようとするとエラー(404 ALERT_NOT_FOUND)が発生します。そのため、アクティブなアラートを対象にする必要があります。
  • リマインダーを削除するためには、ユーザーがどんなリマインダーを削除するのか選択する必要があります。

すべてのリマインダーの取得方法

基本的なリマインダーの作成とやり方は同じです。
違うのは、HTTPのメソッドがPOSTからGETになることくらいでしょうか。

リクエスト

GET /v1/alerts/reminders

これを送ってあげると、このスキルで作成されたリマインダーをすべて取得することができます。ソースコードは、以下の通りです。

const https = require('https');
const urlParse = require('url').parse;
:
const GetReminderHandler = {
  canHandler(handlerInput) {
    const request = handlerInput.requestEnvelope.request;
    return (request.type === 'IntentRequest')
      && (request.intent.name === 'GetReminderIntent);
  },
  async handle(handlerInput) {
    const system = handlerInput.requestEnvelope.context.System;
    const accessRequest = {
      hostname: urlParse(system.apiEndpoint).hostname,
      path: "/v1/alerts/reminders",
      auth: `Bearer ${system.apiAccessToken}`,
      method: "GET",
    };

    const res = await httpGET(accessRequest);
    let speechOutput;
    if (typeof res.totalCount === 'undefined') {
      speechOutput = "リマンダーを取得できませんでした";
    } else {
      speechOutput = res.totalCount;
    }
    return handlerInput.responseBuilder
      .speak(speechOutput)
      .withShouldEndSession(true)
      .withSimpleCard(SKILL_NAME, res.totalCount)
      .getResponse();
  },
};

/**
 * http GET用。
 * リマインダーの取得で利用する。
 * 
 * @request hostname, Authorizationを含むObject
 * @return responseの値、もしくはBodyの値。
 */
const httpGET = (request) => {
  console.log("httpGET");
  const Options = {
    hostname: request.hostname,
    path: request.path,
    method: request.method,
    headers: {
    'Content-Type': 'application/json',
    'Authorization': request.auth,
    },
  };
  return new Promise((resolve, reject) => {
    const clientRequest = https.request(Options, (response) => {
      const chunks = [];
      response.on('data', chunk => {
        chunks.push(chunk);
      }).on('end', () => {
        const hex = chunks.join('');
        let parseObject;
        if (hex != '') {  //bodyがある場合bodyを返す
          parseObject = JSON.parse(hex);
          resolve(parseObject);
        }
        resolve(response);  //bodyがない場合headerを返す
      }).on('error', (error) => {
        reject(error);
      });
    });
    clientRequest.end();
  });
};

:
const skillBuilder = Alexa.SkillBuilders.standard();
exports.handler = skillBuilder
  .addRequestHandlers(
    GetReminderHandler,  //handlerの追加を忘れずに。
    HelpHandler,
    ExitHandler,
    SessionEndedRequestHandler
  )
  .addErrorHandlers(ErrorHandler)
  .lambda();

ソースコードの説明

ところどころ抽出して、説明します。

accessRequestの説明

    const system = handlerInput.requestEnvelope.context.System;
    const accessRequest = {
      hostname: urlParse(system.apiEndpoint).hostname,
      path: "/v1/alerts/reminders",
      auth: `Bearer ${system.apiAccessToken}`,
      method: "GET",
    };

hostnameは、IntentのhandlerInputの値から取得します。Alexa Skills kitによると、APIエンドポイントは、「https://api.amazonalexa.com」となっていますが、私の環境では少し異なるようです。
authは、apiAccessTokenの値を設定します。
methodは、GETです。

httpGETの呼び出しの説明

  async handle(handlerInput) {
  :
    const res = await httpGET(accessRequest);
    let speechOutput;
    if (typeof res.totalCount === 'undefined') {
      speechOutput = "リマンダーを取得できませんでした";
    } else {
      speechOutput = res.totalCount;
    }

handlerには、asyncをつけて、httpGETの呼び出しには、awaitを入れました。
httpGET関数は、ネットのサンプルを参照してPromiseを使いましたが、httpGET処理の完了を担保できないため、その外側にawaitを入れることにしました。
必要な理由はよくわかりませんが、こうしないと応答のheaderまでしかPromiseされないようです。イベントリスナーはPromiseされないのか?まだPromiseを理解しきれておりませんので、現状はこうしました。

httpGET関数の中身

  return new Promise((resolve, reject) => {
    const clientRequest = https.request(Options, (response) => {
      const chunks = [];
      response.on('data', chunk => {
        chunks.push(chunk);
      }).on('end', () => {
        const hex = chunks.join('');
        let parseObject;
        if (hex != '') {  //bodyがある場合bodyを返す
          parseObject = JSON.parse(hex);
          resolve(parseObject);
        }
        resolve(response);  //bodyがない場合headerを返す
      }).on('error', (error) => {
        reject(error);
      });
    });
    clientRequest.end();
  });
};

ネットにあったサンプルをほぼそのまま使っており、どういう処理が適切なのか判断できていませんが、ちょっとだけアレンジしました。
https.requestでGETを送信した応答は、responseでいったんheaderだけが戻ります。
そこからonイベント('data')とonイベント('end')で、bodyをすべて取得します。
bodyを含む場合は、body(すなわち、chunkをJSON.parseしたもの)を返し、bodyを含まない場合は、header(すなわち、response)を返すようにしました。
GETの応答のheaderとbodyを両方取得したかったのですが、いまいちどうやって返せばいいのかわからなかったため苦肉の策です。

res.totalCountの取得

    const res = await httpGET(accessRequest);
    let speechOutput;
    if (typeof res.totalCount === 'undefined') {
      speechOutput = "リマンダーを取得できませんでした";
    } else {
      speechOutput = res.totalCount;
    }

resに対してtotalCountが定義されているかを確認するif文を作りました。
res.totalCountの定義がない場合は、なんらかのエラーが発生することになります。
totalCountをspeechOutputに入れたのは、単に応答をAlexaに話させるためだけです。

リマインダーの削除

リマインダーの削除方法

基本的なリマインダーの取得とやり方は同じです。
違うのは、HTTPのメソッドがGETからDELETEになることと削除するidの設定が必要なことくらいでしょうか。

リクエスト

DELETE /v1/alerts/reminders/{id}

削除するには、削除するリマインダーのidが必要です。このidは、alertTokenのことであり、リマインダーの取得で、値がわかります。
リマインダーを作成すると作成され、一意に識別されるものと思われますので、リマインダー作成時に取得することもありとは思いますが、リマインダーの削除では、完了済みのリマインダーは削除できないため、削除前に改めてstatusを確認しつつ、削除するのがよいと思います。
ソースコードは以下の通りです。

const DeleteReminderHandler = {
  canHandle(handlerInput) {
    const request = handlerInput.requestEnvelope.request;
    return (request.type === 'IntentRequest')
      && (request.intent.name === 'DeleteReminderIntent');
  },
  async handle(handlerInput) {
    const system = handlerInput.requestEnvelope.context.System;
    const getRequest = {
      hostname: urlParse(system.apiEndpoint).hostname,
      path: "/v1/alerts/reminders",
      auth: `Bearer ${system.apiAccessToken}`,
      method: "GET",
    };
    const reminders = await httpGET(getRequest);
    const totalCount = reminders.totalCount;
    let speechOutput;
    const alertToken = [];
    if (typeof totalCount === 'undefined') {  //bodyが正常でない場合
      speechOutput ="削除できませんでした。";      
    } else {  //bodyが正常な場合, alertTokenをすべて取得して削除。
      for (let i = 0 ; i < totalCount; i++) {
        if (reminders.alerts[i].status === 'ON') {
          alertToken.push(reminders.alerts[i].alertToken);
        }
      }
      if (alertToken.length == 0) {
        speechOutput = "有効なリマインダーはありませんでした。";
      } else {
        await deleteReminders(system, alertToken);
        speechOutput = "削除しました。";
      }
    }
    return handlerInput.responseBuilder
      .speak(speechOutput)
      .withShouldEndSession(true)
      .withSimpleCard(SKILL_NAME, speechOutput)
      .getResponse();
  },
};

const deleteReminders = async (system, alertToken) => {
  console.log("DELETE");
  for (let i = 0 ; i < alertToken.length; i++) {
    alertToken[i] = encodeURIComponent(alertToken[i]);
    const deleteRequest = {
      hostname: urlParse(system.apiEndpoint).hostname,
      path: `/v1/alerts/reminders/${alertToken[i]}`,
      auth: `Bearer ${system.apiAccessToken}`,
      method: "DELETE",
    };
    const res = await httpGET(deleteRequest);
  }
};
:
const skillBuilder = Alexa.SkillBuilders.standard();
exports.handler = skillBuilder
  .addRequestHandlers(
    GetReminderHandler,  //handlerの追加を忘れずに。
    DeleteReminderHandler,  //handlerの追加も忘れずに。
    HelpHandler,
    ExitHandler,
    SessionEndedRequestHandler
  )
  .addErrorHandlers(ErrorHandler)
  .lambda();

ソースコードの説明

ところどころ抽出して、説明します。

まずはリマインダーの取得の説明

  async handle(handlerInput) {
    const system = handlerInput.requestEnvelope.context.System;
    const getRequest = {
      hostname: urlParse(system.apiEndpoint).hostname,
      path: "/v1/alerts/reminders",
      auth: `Bearer ${system.apiAccessToken}`,
      method: "GET",
    };
    const reminders = await httpGET(getRequest);
    const totalCount = reminders.totalCount;

いったん、すべてのリマインダーを取得し、totalCountを取得します。

alertToken取得と、削除

    const alertToken = [];
    if (typeof totalCount === 'undefined') {  //bodyが正常でない場合
      speechOutput ="削除できませんでした。";      
    } else {  //bodyが正常な場合, alertTokenをすべて取得して削除。
      for (let i = 0 ; i < totalCount; i++) {
        if (reminders.alerts[i].status === 'ON') {
          alertToken.push(reminders.alerts[i].alertToken);
        }
      }
      if (alertToken.length == 0) {
        speechOutput = "有効なリマインダーはありませんでした。";
      } else {
        await deleteReminders(system, alertToken);
        speechOutput = "削除しました。";
      }
    }

totalCountの型がundefinedになる場合、Bodyを取得できていないため処理を終了します。
取得できた場合は、alertTokenをすべて取得します。
そのなかでstatusがONとなっているアクティブなリマインダーを取得します。
そのアクティブなリマインダーに対して、deleteReminders関数で削除します。

deleteReminers関数

const deleteReminders = async (system, alertToken) => {
  console.log("DELETE");
  for (let i = 0 ; i < alertToken.length; i++) {
    alertToken[i] = encodeURIComponent(alertToken[i]);
    console.log(`accessToken:${alertToken[i]}`);
    const deleteRequest = {
      hostname: urlParse(system.apiEndpoint).hostname,
      path: `/v1/alerts/reminders/${alertToken[i]}`,
      auth: `Bearer ${system.apiAccessToken}`,
      method: "DELETE",
    };

    const res = await httpGET(deleteRequest);
    if (!(typeof res.statusCode === "undefined")) console.log("delete res.statusCode:" + res.statusCode);
  }
};

単純にalertTokenの数だけ、リマインダー削除を実施します。
`encodeURIComponent(alertToken[i])'は、文字列が適切かどうか確認しているだけです。ここでは処理は何もしていません。
削除するのにhttpGET関数を再利用しています。削除のmethodはDELTEであるため、関数名と一致しないのはいまいちでした。別の関数名をつけたほうがよかったですね。

参考情報

Alexa Skill Kit すべてのリマインダーの取得

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Alexa Reminder skillですべてのリマインダーの取得や削除

Alexa Reminder skillで「すべてのリマインダーの取得」と「リマインダーの削除」を試してみたので、その内容をまとめます。
「リマインダーの作成」についてはいろいろネットを検索するとでてきましたが、「すべてのリマインダーの取得」や「リマインダーの削除」などについての記事は見つけることができませんでしたので、ここにメモを残します。

前提

まず、「すべてのリマインダーの取得」にあたり、「リマインダーの作成」はできているものとします。
以下の記事で作成済みです。
Alexa Reminder skillのリマインダー作成時のtriggerについて
Alexa Reminder skillでリマインダー作成時にInvalid Token、DEVICE_NOT_SUPPORTED、UNAUTHORIZEDとなった場合の対策

注意事項

「すべてのリマインダーを取得」する場合、Alexaのリマインダーになんらかのリマインダーが設定されていることが望ましいと思います。
今回、取得できるリマンダーは、自分で作成したリスキル上で追加したものに限られます。
別のスキルやもともとAlexaに標準で搭載されているリマインダーは、取得できていません。
そういったリマインダーはセッション外として別途アクセス許可が必要なようなため、試せていません。
そのやり方は、以下にあるようですが、私にはよくわかりませんでした。
AlexaリマインダーAPIのセッション内およびセッション外の動作

すべてのリマインダーの取得

すべてのリマインダーを取得する理由

  • リマインダーを削除するためには、削除するリマインダーのid、すなわちalertTokenが必要になります。
  • 削除可能なリマインダーは、アクティブなもの(statusがON)だけに限られます。完了済みのもの(statusがCOMPLETE)は削除できず、削除しようとするとエラー(404 ALERT_NOT_FOUND)が発生します。そのため、アクティブなアラートを対象にする必要があります。
  • リマインダーを削除するためには、ユーザーがどんなリマインダーを削除するのか選択する必要があります。

すべてのリマインダーの取得方法

基本的なリマインダーの作成とやり方は同じです。
違うのは、HTTPのメソッドがPOSTからGETになることくらいでしょうか。

リクエスト

GET /v1/alerts/reminders

これを送ってあげると、このスキルで作成されたリマインダーをすべて取得することができます。ソースコードは、以下の通りです。

const https = require('https');
const urlParse = require('url').parse;
:
const GetReminderHandler = {
  canHandler(handlerInput) {
    const request = handlerInput.requestEnvelope.request;
    return (request.type === 'IntentRequest')
      && (request.intent.name === 'GetReminderIntent);
  },
  async handle(handlerInput) {
    const system = handlerInput.requestEnvelope.context.System;
    const accessRequest = {
      hostname: urlParse(system.apiEndpoint).hostname,
      path: "/v1/alerts/reminders",
      auth: `Bearer ${system.apiAccessToken}`,
      method: "GET",
    };

    const res = await httpGET(accessRequest);
    let speechOutput;
    if (typeof res.totalCount === 'undefined') {
      speechOutput = "リマンダーを取得できませんでした";
    } else {
      speechOutput = res.totalCount;
    }
    return handlerInput.responseBuilder
      .speak(speechOutput)
      .withShouldEndSession(true)
      .withSimpleCard(SKILL_NAME, res.totalCount)
      .getResponse();
  },
};

/**
 * http GET用。
 * リマインダーの取得で利用する。
 * 
 * @request hostname, Authorizationを含むObject
 * @return responseの値、もしくはBodyの値。
 */
const httpGET = (request) => {
  console.log("httpGET");
  const Options = {
    hostname: request.hostname,
    path: request.path,
    method: request.method,
    headers: {
    'Content-Type': 'application/json',
    'Authorization': request.auth,
    },
  };
  return new Promise((resolve, reject) => {
    const clientRequest = https.request(Options, (response) => {
      const chunks = [];
      response.on('data', chunk => {
        chunks.push(chunk);
      }).on('end', () => {
        const hex = chunks.join('');
        let parseObject;
        if (hex != '') {  //bodyがある場合bodyを返す
          parseObject = JSON.parse(hex);
          resolve(parseObject);
        }
        resolve(response);  //bodyがない場合headerを返す
      }).on('error', (error) => {
        reject(error);
      });
    });
    clientRequest.end();
  });
};

:
const skillBuilder = Alexa.SkillBuilders.standard();
exports.handler = skillBuilder
  .addRequestHandlers(
    GetReminderHandler,  //handlerの追加を忘れずに。
    HelpHandler,
    ExitHandler,
    SessionEndedRequestHandler
  )
  .addErrorHandlers(ErrorHandler)
  .lambda();

ソースコードの説明

ところどころ抽出して、説明します。

accessRequestの説明

    const system = handlerInput.requestEnvelope.context.System;
    const accessRequest = {
      hostname: urlParse(system.apiEndpoint).hostname,
      path: "/v1/alerts/reminders",
      auth: `Bearer ${system.apiAccessToken}`,
      method: "GET",
    };

hostnameは、IntentのhandlerInputの値から取得します。Alexa Skills kitによると、APIエンドポイントは、「https://api.amazonalexa.com」となっていますが、私の環境では少し異なるようです。
authは、apiAccessTokenの値を設定します。
methodは、GETです。

httpGETの呼び出しの説明

  async handle(handlerInput) {
  :
    const res = await httpGET(accessRequest);
    let speechOutput;
    if (typeof res.totalCount === 'undefined') {
      speechOutput = "リマンダーを取得できませんでした";
    } else {
      speechOutput = res.totalCount;
    }

handlerには、asyncをつけて、httpGETの呼び出しには、awaitを入れました。
httpGET関数は、ネットのサンプルを参照してPromiseを使いましたが、httpGET処理の完了を担保できないため、その外側にawaitを入れることにしました。
必要な理由はよくわかりませんが、こうしないと応答のheaderまでしかPromiseされないようです。イベントリスナーはPromiseされないのか?まだPromiseを理解しきれておりませんので、現状はこうしました。

httpGET関数の中身

  return new Promise((resolve, reject) => {
    const clientRequest = https.request(Options, (response) => {
      const chunks = [];
      response.on('data', chunk => {
        chunks.push(chunk);
      }).on('end', () => {
        const hex = chunks.join('');
        let parseObject;
        if (hex != '') {  //bodyがある場合bodyを返す
          parseObject = JSON.parse(hex);
          resolve(parseObject);
        }
        resolve(response);  //bodyがない場合headerを返す
      }).on('error', (error) => {
        reject(error);
      });
    });
    clientRequest.end();
  });
};

ネットにあったサンプルをほぼそのまま使っており、どういう処理が適切なのか判断できていませんが、ちょっとだけアレンジしました。
https.requestでGETを送信した応答は、responseでいったんheaderだけが戻ります。
そこからonイベント('data')とonイベント('end')で、bodyをすべて取得します。
bodyを含む場合は、body(すなわち、chunkをJSON.parseしたもの)を返し、bodyを含まない場合は、header(すなわち、response)を返すようにしました。
GETの応答のheaderとbodyを両方取得したかったのですが、いまいちどうやって返せばいいのかわからなかったため苦肉の策です。

res.totalCountの取得

    const res = await httpGET(accessRequest);
    let speechOutput;
    if (typeof res.totalCount === 'undefined') {
      speechOutput = "リマンダーを取得できませんでした";
    } else {
      speechOutput = res.totalCount;
    }

resに対してtotalCountが定義されているかを確認するif文を作りました。
res.totalCountの定義がない場合は、なんらかのエラーが発生することになります。
totalCountをspeechOutputに入れたのは、単に応答をAlexaに話させるためだけです。

参考情報

Alexa Skill Kit すべてのリマインダーの取得

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Alexa Reminder skillですべてのリマインダーの取得

Alexa Reminder skillで「すべてのリマインダーの取得」と「リマインダーの削除」を試してみたので、その内容をまとめます。
「リマインダーの作成」についてはいろいろネットを検索するとでてきましたが、「すべてのリマインダーの取得」や「リマインダーの削除」などについての記事は見つけることができませんでしたので、ここにメモを残します。

前提

まず、「すべてのリマインダーの取得」にあたり、「リマインダーの作成」はできているものとします。
以下の記事で作成済みです。
Alexa Reminder skillのリマインダー作成時のtriggerについて
Alexa Reminder skillでリマインダー作成時にInvalid Token、DEVICE_NOT_SUPPORTED、UNAUTHORIZEDとなった場合の対策

注意事項

「すべてのリマインダーを取得」する場合、Alexaのリマインダーにスキルで作成したリマインダーが設定されていることが望ましいと思います。
今回、取得できるリマンダーは、自分で作成したスキル上で追加したものに限られます。
別のスキルやもともとAlexaに標準で搭載されているリマインダーは、取得できません。
そういったリマインダーはセッション外として別途アクセス許可が必要なようなため、試せていません。
そのやり方は、以下にあるようですが、私にはよくわかりませんでした。
AlexaリマインダーAPIのセッション内およびセッション外の動作

すべてのリマインダーの取得

すべてのリマインダーを取得する理由

  • リマインダーを削除するためには、削除するリマインダーのid、すなわちalertTokenが必要になります。
  • 削除可能なリマインダーは、アクティブなもの(statusがON)だけに限られます。完了済みのもの(statusがCOMPLETE)は削除できず、削除しようとするとエラー(404 ALERT_NOT_FOUND)が発生します。そのため、アクティブなアラートを対象にする必要があります。
  • リマインダーを削除するためには、ユーザーがどんなリマインダーを削除するのか選択する必要があります。

すべてのリマインダーの取得方法

基本的なリマインダーの作成とやり方は同じです。
違うのは、HTTPのメソッドがPOSTからGETになることくらいでしょうか。

リクエスト

GET /v1/alerts/reminders

これを送ってあげると、このスキルで作成されたリマインダーをすべて取得することができます。ソースコードは、以下の通りです。

const https = require('https');
const urlParse = require('url').parse;
:
const GetReminderHandler = {
  canHandler(handlerInput) {
    const request = handlerInput.requestEnvelope.request;
    return (request.type === 'IntentRequest')
      && (request.intent.name === 'GetReminderIntent);
  },
  async handle(handlerInput) {
    const system = handlerInput.requestEnvelope.context.System;
    const accessRequest = {
      hostname: urlParse(system.apiEndpoint).hostname,
      path: "/v1/alerts/reminders",
      auth: `Bearer ${system.apiAccessToken}`,
      method: "GET",
    };

    const res = await httpGET(accessRequest);
    let speechOutput;
    if (typeof res.totalCount === 'undefined') {
      speechOutput = "リマンダーを取得できませんでした";
    } else {
      speechOutput = res.totalCount;
    }
    return handlerInput.responseBuilder
      .speak(speechOutput)
      .withShouldEndSession(true)
      .withSimpleCard(SKILL_NAME, res.totalCount)
      .getResponse();
  },
};

/**
 * http GET用。
 * リマインダーの取得で利用する。
 * 
 * @request hostname, Authorizationを含むObject
 * @return responseの値、もしくはBodyの値。
 */
const httpGET = (request) => {
  console.log("httpGET");
  const Options = {
    hostname: request.hostname,
    path: request.path,
    method: request.method,
    headers: {
    'Content-Type': 'application/json',
    'Authorization': request.auth,
    },
  };
  return new Promise((resolve, reject) => {
    const clientRequest = https.request(Options, (response) => {
      const chunks = [];
      response.on('data', chunk => {
        chunks.push(chunk);
      }).on('end', () => {
        const hex = chunks.join('');
        let parseObject;
        if (hex != '') {  //bodyがある場合bodyを返す
          parseObject = JSON.parse(hex);
          resolve(parseObject);
        }
        resolve(response);  //bodyがない場合headerを返す
      }).on('error', (error) => {
        reject(error);
      });
    });
    clientRequest.end();
  });
};

:
const skillBuilder = Alexa.SkillBuilders.standard();
exports.handler = skillBuilder
  .addRequestHandlers(
    GetReminderHandler,  //handlerの追加を忘れずに。
    HelpHandler,
    ExitHandler,
    SessionEndedRequestHandler
  )
  .addErrorHandlers(ErrorHandler)
  .lambda();

ソースコードの説明

ところどころ抽出して、説明します。

accessRequestの説明

    const system = handlerInput.requestEnvelope.context.System;
    const accessRequest = {
      hostname: urlParse(system.apiEndpoint).hostname,
      path: "/v1/alerts/reminders",
      auth: `Bearer ${system.apiAccessToken}`,
      method: "GET",
    };

hostnameは、IntentのhandlerInputの値から取得します。Alexa Skills kitによると、APIエンドポイントは、「https://api.amazonalexa.com」となっていますが、私の環境では少し異なるようです。
authは、apiAccessTokenの値を設定します。
methodは、GETです。

httpGETの呼び出しの説明

  async handle(handlerInput) {
  :
    const res = await httpGET(accessRequest);
    let speechOutput;
    if (typeof res.totalCount === 'undefined') {
      speechOutput = "リマンダーを取得できませんでした";
    } else {
      speechOutput = res.totalCount;
    }

handlerには、asyncをつけて、httpGETの呼び出しには、awaitを入れました。
httpGET関数は、ネットのサンプルを参照してPromiseを使いましたが、httpGET処理の完了を担保できないため、その外側にawaitを入れることにしました。
必要な理由はよくわかりませんが、こうしないと応答のheaderまでしかPromiseされないようです。イベントリスナーはPromiseされないのか?まだPromiseを理解しきれておりませんので、現状はこうしました。

httpGET関数の中身

  return new Promise((resolve, reject) => {
    const clientRequest = https.request(Options, (response) => {
      const chunks = [];
      response.on('data', chunk => {
        chunks.push(chunk);
      }).on('end', () => {
        const hex = chunks.join('');
        let parseObject;
        if (hex != '') {  //bodyがある場合bodyを返す
          parseObject = JSON.parse(hex);
          resolve(parseObject);
        }
        resolve(response);  //bodyがない場合headerを返す
      }).on('error', (error) => {
        reject(error);
      });
    });
    clientRequest.end();
  });
};

ネットにあったサンプルをほぼそのまま使っており、どういう処理が適切なのか判断できていませんが、ちょっとだけアレンジしました。
https.requestでGETを送信した応答は、responseでいったんheaderだけが戻ります。
そこからonイベント('data')とonイベント('end')で、bodyをすべて取得します。
bodyを含む場合は、body(すなわち、chunkをJSON.parseしたもの)を返し、bodyを含まない場合は、header(すなわち、response)を返すようにしました。
GETの応答のheaderとbodyを両方取得したかったのですが、いまいちどうやって返せばいいのかわからなかったため苦肉の策です。

res.totalCountの取得

    const res = await httpGET(accessRequest);
    let speechOutput;
    if (typeof res.totalCount === 'undefined') {
      speechOutput = "リマンダーを取得できませんでした";
    } else {
      speechOutput = res.totalCount;
    }

resに対してtotalCountが定義されているかを確認するif文を作りました。
res.totalCountの定義がない場合は、なんらかのエラーが発生することになります。
totalCountをspeechOutputに入れたのは、単に応答をAlexaに話させるためだけです。

リマインダーの削除

リマインダーの削除方法

基本的なリマインダーの取得とやり方は同じです。
違うのは、HTTPのメソッドがGETからDELETEになることと削除するidの設定が必要なことくらいでしょうか。

リクエスト

DELETE /v1/alerts/reminders/{id}

削除するには、削除するリマインダーのidが必要です。このidは、alertTokenのことであり、リマインダーの取得で、値がわかります。
リマインダーを作成すると作成され、一意に識別されるものと思われますので、リマインダー作成時に取得することもありとは思いますが、リマインダーの削除では、完了済みのリマインダーは削除できないため、削除前に改めてstatusを確認しつつ、削除するのがよいと思います

参考情報

Alexa Skill Kit すべてのリマインダーの取得

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Node.js v12でTLSv1.3のテストサーバを手軽に構築

前置き

Node.jsがv12でTLSv1.3に対応しました。(リリースノート
クライアントアプリケーションの開発テストをしているときにTLSv1.3のサーバが必要になることがあるのですが、単純なリクエスト・レスポンスを確認するだけなら、Node.jsはOSを問わず手軽に用意できて便利です。
この記事では、WindowsおよびMacでNode.jsを使ったTLSv1.3対応のHTTPSサーバを構築する手順をまとめます。

環境

Windows

  • OS: Windows 10 Version 1809 (64bit)
  • Node.js: v12.2.0
  • OpenSSL: OpenSSL 1.1.1b 26 Feb 2019

Mac

  • OS: Mac OS X 10.14.3 Mojave
  • nodebrew: 1.0.1
  • Node.js: v12.2.0
  • OpenSSL: LibreSSL 2.6.5

Node.jsのインストール

Windows

Node.jsの公式サイトより最新版のインストーラをダウンロードします。
ダウンロードしたインストーラを起動し、画面の指示に従ってインストールを完了させます。

Mac

バージョン切替やアンインストール1がしやすいように、公式のパッケージではなくバージョン管理ツールを使ってNode.jsをインストールします。
今回は nodebrew というツールを使用します。
nodebrewのREADMEに従い、ターミナルを起動してセットアップを行います。
ターミナルは、アプリケーションフォルダ内のユーティリティフォルダの中にあります。

ターミナル
# nodebrewのセットアップを行うコマンド
$ curl -L git.io/nodebrew | perl - setup
$ echo 'export PATH=$HOME/.nodebrew/current/bin:$PATH' >> ~/.bash_profile
$ source ~/.bash_profile
$ nodebrew help
nodebrew 1.0.1
# 以下略

nodebrewで最新版のNode.jsをインストールし、それを使用するように指定します。

ターミナル
$ nodebrew install latest
$ nodebrew use latest
$ node -v
v12.2.0

OpenSSLのインストール

Windows

OpenSSLのWindowsバイナリを配布しているサイトからダウンロードします。

今回はICS DownloadOpenSSL Binaries Win-64 1.1.1b requires ICS V8.57 or laterを使用します。
ダウンロードしたzipファイルは解凍しておきます。

OpenSSL設定ファイルの準備

使用するバイナリによっては、OpenSSLの設定ファイルの準備が別途必要になります。
上記ICS Downloadのバイナリの場合、以下のように設定ファイルを準備します。

  1. 適当な場所に openssl.cnf という名前のファイルを作成します。
  2. 作成した openssl.cnf ファイルをメモ帳などで開き、OpenSSL公式のソースに含まれているopenssl.cnfのサンプルの内容をまるごとコピー&ペーストして保存します。
  3. C:\Program Files\Common Files フォルダの中に SSL という名前のフォルダを作成します。
  4. 作成した SSL フォルダの中に openssl.cnf ファイルを移動させます。

Mac

OSにあらかじめ入っているOpenSSL(LibreSSL)を使用しますので、インストールは不要です。

秘密鍵とサーバ証明書の作成

Windows

ダウンロードしたWindowsバイナリのフォルダの中にある openssl.exe を起動し、表示されたコマンドプロンプトで、秘密鍵とサーバ証明書を作成する以下のコマンドを実行します。

-subj オプションの CN= の値には、サーバのIPアドレスを指定します。実際のテスト環境に合わせて値を書き換えてください。
今回の説明では 127.0.0.1 のIPアドレスでサーバ証明書を作成します。

openssl.exe
# 秘密鍵とサーバ証明書を作成するコマンド
OpenSSL> req -subj '/C=JP/ST=TestState/O=TestCompany/CN=127.0.0.1' \
 -x509 -nodes -days 3650 -newkey rsa:2048 \
 -keyout server-key.pem \
 -out server-cert.pem

コマンド実行後、openssl.exe があるフォルダの中に秘密鍵(server-key.pem)とサーバ証明書(server-cert.pem)のファイルが作成されていることを確認します。

Mac

ターミナルで、秘密鍵とサーバ証明書を作成する以下のコマンドを実行します。

-subj オプションの CN= の値には、サーバのIPアドレスを指定します。実際のテスト環境に合わせて値を書き換えてください。
今回の説明では 127.0.0.1 のIPアドレスでサーバ証明書を作成します。

ターミナル
$ openssl req -subj '/C=JP/ST=TestState/O=TestCompany/CN=127.0.0.1' \
 -x509 -nodes -days 3650 -newkey rsa:2048 \
 -keyout ~/server-key.pem \
 -out ~/server-cert.pem

コマンド実行後、ホームフォルダの中に秘密鍵(server-key.pem)とサーバ証明書(server-cert.pem)のファイルが作成されていることを確認します。

サーバプログラムの作成

Windows

  1. 任意の作業フォルダを作成します。
  2. 先ほど作成した秘密鍵(server-key.pem)とサーバ証明書(server-cert.pem)のファイルを作業フォルダの中に移動させます。
  3. 作業フォルダの中に test-server.js という名前のファイルを作成します。(ファイル名は任意です)
  4. 作成した test-server.js ファイルをメモ帳などで開き、後述するサーバプログラムの内容をコピー&ペーストして保存します。

Mac

作業フォルダを作成します。(フォルダ名は任意です)

ターミナル
$ mkdir ~/test-server

先ほど作成した秘密鍵(server-key.pem)とサーバ証明書(server-cert.pem)のファイルを、作業フォルダの中に移動させます。

ターミナル
$ mv ~/server-key.pem ~/test-server/
$ mv ~/server-cert.pem ~/test-server/

test-server.js ファイルを作成し、後述するサーバプログラムの内容をコピー&ペーストして保存します。(ファイル名は任意です)

ターミナル
$ vi ~/test-server/test-server.js

viコマンドでファイル編集画面が表示された後は、サーバプログラムの内容をコピー&ペースト→escキーを押す→:wqと入力するとファイルを保存して終了できます。

サーバプログラム

以下のプログラム内で指定しているサーバのポート番号やIPアドレスの値は、実際のテスト環境に合わせて書き換えてください。
今回の説明では、ポート番号に 8443、IPアドレスに 127.0.0.1 を使用します。

test-server.js
const https = require('https')
const fs = require('fs')
const port = 8443        // サーバのポート番号を指定
const host = '127.0.0.1' // サーバのIPアドレスを指定

const options = {
  key: fs.readFileSync('server-key.pem'),   // 秘密鍵のファイルを指定
  cert: fs.readFileSync('server-cert.pem'), // サーバ証明書のファイルを指定
  maxVersion: 'TLSv1.3',
  minVersion: 'TLSv1.3',
}

// サーバインスタンス作成
const server = https.createServer(options)

// リクエストイベントリスナー
server.on('request', (req, res) => {
  // テスト用のリクエストログを適宜出力
  console.log('**********************')
  console.log('*   Client Request   *')
  console.log('**********************')
  console.log('HTTP Method:', req.method)
  console.log('Request URL:', req.url)
  console.log('HTTP Version:', req.httpVersion)
  console.log('HTTP Header:', req.headers)
  req.on('data', (chunk) => {
    console.log('HTTP Body:', chunk.toString('utf8'))
  })
  // レスポンス送信
  res.setHeader('Content-Type', 'text/plain; charset=utf-8')
  res.writeHead(200)
  res.write('Hello World!\n')
  res.end()
})

// サーバ起動
server.listen(port, host, () => {
  console.log(`Starts the server at https://${host}:${port}/`)
})

サーバの起動

Windows

Windowsメニューから Node.js command prompt を起動し、作業フォルダへ移動します。
(Windows PowerShellやコマンドプロンプトでも同じように実行できます)

Node.js_command_prompt
# 作業フォルダへ移動するコマンド
> cd 作業フォルダのパス

nodeコマンドでサーバプログラムを実行します。

Node.js_command_prompt
# サーバプログラムを実行するコマンド
> node test-server.js
Starts the server at https://127.0.0.1:8443/

Mac

ターミナルで作業フォルダへ移動し、nodeコマンドでサーバプログラムを実行します。

ターミナル
$ cd ~/test-server/
$ node test-server.js
Starts the server at https://127.0.0.1:8443/

接続の確認

TLSv1.3が使用可能なブラウザで https://サーバのIPアドレス:ポート番号/ へアクセスします。
自己署名証明書によるエラーは無視して、アクセスを続けてください。
アクセスに成功すると、ブラウザに「Hello World!」と表示され、サーバを起動しているコマンドプロンプトやターミナルの画面にリクエストのログが出力されます。

Node.js_command_prompt
# WindowsのFirefoxでアクセスした場合のログ出力例
**********************
*   Client Request   *
**********************
HTTP Method: GET
Request URL: /
HTTP Version: 1.1
HTTP Header: {
  host: '127.0.0.1:8443',
  'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:66.0) Gecko/20100101 Firefox/66.0',
  accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
  'accept-language': 'ja,en-US;q=0.7,en;q=0.3',
  'accept-encoding': 'gzip, deflate, br',
  dnt: '1',
  connection: 'keep-alive',
  'upgrade-insecure-requests': '1'
}

ブラウザの開発ツールを開いた状態でアクセスすると、TLSv1.3のプロトコルバージョンで接続していることが確認できます。
Firefoxでのアクセス確認結果.png

サーバを停止する際は、サーバを起動しているコマンドプロンプトやターミナルの画面で Ctrl+C を押してください。

以上でサーバ構築は完了です。

後書き

ApacheやnginxでHTTPSサーバを立てる場合、慣れていないと設定ファイルの作成などでつまずき構築に時間がかかってしまうことがあります。
詳細なログやカスタマイズは不要でとりあえずテストサーバを用意したいようなとき、Node.jsは便利な選択肢のひとつになるのではないでしょうか。


参考サイト

本サーバ構築にあたり、以下のサイトの記事を特に参考に致しました。
ありがとうございました。


  1. MacにはWindowsのようなプログラムのアンインストール機能がないため、公式のパッケージでインストールをすると、テストサーバとして使用した後でNode.jsをきれいにアンインストールするのがやや面倒になります。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

便利ページ:トレンドキーワードを取得してみた

前回までに、なにかと便利なページを作成しました。

 便利ページ:Javascriptでちょっとした便利な機能を作ってみた
 便利ページ:自分のQiita記事を一覧表示
 便利ページ:元号を変換してみた

今回は巷で噂のトレンドキーワードを取得するするページを追加しました。
とは言っても、以下の以前の投稿内容と同じで、お便利ページに移植しただけです。
 Dialogflowと連携してLINE Botを作る

毎度の通り、デモページとGitHubです。

GitHub
 https://github.com/poruruba/utilities

デモページ
 https://poruruba.github.io/utilities/

サーバ側の実装

トレンドキーワードを収集するために、「Twitter REST API」を使います。
Twitter Developerアカウントに登録されている必要があります。

swagger.yaml
  /trendword:
    post:
      x-swagger-router-controller: routing
      operationId: trendword
      parameters:
        - in: body
          name: body
          schema:
            type: object
      responses:
        200:
          description: Success
          schema:
            type: object
index.js
var fetch = require('node-fetch');
const { URLSearchParams } = require('url');

const TWITTER_API_KEY = process.env.TWITTER_API_KEY || TwitterアプリのAPI key;
const TWITTER_API_SECRET_KEY = process.env.TWITTER_API_SECRET_KEY || TwitterアプリのAPI secret key;
const YAHOO_WOEID = process.env.YAHOO_WOEID || 【トレンドを取得したい場所のWOEID; 
const NUM_OF_PICKUP = 10;

exports.handler = async (event, context, callback) => {
    if( event.path == '/trendword' ){
        var body = JSON.parse(event.body);
        if( body.apikey != SERVER_APIKEY )
            throw 'apikey mismatch';

        var trends = await get_trendlist();
        return new Response({ result: 'OK', trends: pickup_list(array_shuffle(trends.trends)) });
    }
};

function array_shuffle(array){
    for(var i = array.length - 1; i > 0; i--) {
        var j = Math.floor(Math.random() * (i + 1));
        var tmp = array[i];
        array[i] = array[j];
        array[j] = tmp;
    }

    return array;
}

function pickup_list(list){
    var pickup = [];
    for( var i = 0 ; i < NUM_OF_PICKUP ; i++ ){
        var name = list[i].name;
        if( name.slice(0, 1) == '#' || name.slice(0, 1) == '?' )
            name = name.slice(1);

        pickup.push(name);
    }

    return pickup;
}

function get_trendlist(){
    var body = {
        grant_type: 'client_credentials'
    };
    return do_post_secret('https://api.twitter.com/oauth2/token', body, TWITTER_API_KEY, TWITTER_API_SECRET_KEY)
    .then(result =>{
        var body = {
            id: YAHOO_WOEID
        };
        return do_get_token_text('https://api.twitter.com/1.1/trends/place.json', body, result.access_token);
    })
    .then(result =>{
        var list = JSON.parse(result);
//        console.log(list);

        return list[0];
    });
}

function do_post_secret(url, body, client_id, client_secret){
    var data = new URLSearchParams();
    for( var name in body )
        data.append(name, body[name]);

    var basic = new Buffer(client_id + ':' + client_secret).toString('base64');

    return fetch(url, {
        method : 'POST',
        body : data,
        headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization' : 'Basic ' + basic }
    })
    .then((response) => {
        if( response.status != 200 )
            throw 'status is not 200';
        return response.json();
    });
}

function do_get_token_text(url, qs, token){
    var params = new URLSearchParams();
    for( var key in qs )
        params.set(key, qs[key] );

    console.log(url + '?' + params.toString());
    return fetch(url + '?' + params.toString(), {
        method : 'GET',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization' : 'Bearer ' + token }
    })
    .then((response) => {
        if( response.status != 200 )
            throw 'status is not 200';
        return response.text();
    });
}

以下の部分を環境に合わせて書き換えます。

【TwitterアプリのAPI key】
【TwitterアプリのAPI secret key】
【トレンドを取得したい場所のWOEID】

動作確認

ブラウザにはこんな感じに表示されます。

image.png

以上

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む