SwaggerでLambdaのデバッグ環境を作る(1)

自分は、AWS LambdaのNode.jsを使うことが多いため、Lambdaで動作するNode.jsがローカルに立ち上げたSwaggerによるRESTful環境で動作すると都合がよいです。

また、ローカルに立ち上げたSwaggerによるRESTful環境でデバッグしたNode.jsのコードがそのままAWSのLambdaで動作すると都合がよいです。

そこで、SwaggerによるRESTful環境で、AWS LambdaのNode.jsが動作する環境を構築します。

最初は、デバッグ環境を構築し、簡単なRESTful呼び出しを実装し、Lambdaに配備するところまでです。

次回以降は、それを使って、AlexaやActions on GoogleやClovaのデバッグにも流用する手順を示したいと思います。

話はそれますが、最近じわじわ下記の記事のいいねが増えているようです。下記の記事をパワーアップさせたものですので、ぜひ本記事も参考にして下さい。

 Swagger定義ファイルにSecurityDefinitionsを定義する

Swaggerとはなんぞは、の方は、以下を参考にしてください。

 SwaggerでRESTful環境を構築する

Gitに上げておきます。

 https://github.com/poruruba/swagger_template

以下、本記事の続編です。


Swaggerプロジェクトを作成する

まずは、通常通り、Swaggerプロジェクトを作成します。

途中で聞かれるフレームワークにはexpressを選択します。


swagger project create lambda_lab

cd lambda_lab

npm install


次に、app.jsを以下のコードに書き換えます。


app.js

'use strict';

var SwaggerExpress = require('swagger-express-mw');
var express = require('express');
var app = express();
module.exports = app; // for testing

app.use(express.static('public'));

var cors = require('cors');
app.use(cors());
require('dotenv').config();

var session = require('express-session');
app.use(session({
secret: "secret key",
resave: false,
saveUninitialized: false,
cookie: { secure: true }
}));

var config = {
appRoot: __dirname // required config
};

var jwt_decode = require('jwt-decode');

config.swaggerSecurityHandlers = {
basicAuth : function (req, authOrSecDef, scopesOrApiKey, cb) {
try{
if( req.headers.authorization ){
var basic = req.headers.authorization.trim();
if(basic.toLowerCase().startsWith('basic '))
basic = basic.slice(6).trim();

var buf = new Buffer(basic, 'base64');
var ascii = buf.toString('ascii');

req.requestContext = {
basicAuth : {
basic : ascii.split(':')
}
};
}else{
cb(new Error("Authorization not defined"));
return;
}

cb();
}catch(error){
cb(error);
}
},
tokenAuth : function (req, authOrSecDef, scopesOrApiKey, cb) {
try{
if(scopesOrApiKey){
var decoded = jwt_decode(scopesOrApiKey);
req.requestContext = {
authorizer : {
claims : decoded
}
};
}else{
cb(new Error("Authorization not defined") );
return;
}

cb();
}catch(error){
cb(error);
}
},
jwtAuth: function (req, authOrSecDef, scopesOrApiKey, cb) {
try{
if( req.headers.authorization ){
var decoded = jwt_decode(req.headers.authorization);
req.requestContext = {
jwtAuth : {
claims : decoded
}
};
var claims = {
claims : decode,
issuer: decoded.iss,
id: decoded.sub,
email: decoded.email
};
var buffer = new Buffer(JSON.stringify(claims));
req.headers['x-endpoint-api-userinfo'] = buffer.toString('base64');
}else{
cb(new Error("Authorization not defined") );
return;
}

cb();
}catch(error){
cb(error);
}
},
apikeyAuth: function (req, authOrSecDef, scopesOrApiKey, cb) {
try{
if( scopesOrApiKey ){
req.requestContext = {
apikeyAuth : {
apikey : scopesOrApiKey
}
};
}else{
cb(new Error("X-API-KEY not defined") );
return;
}

cb();
}catch(error){
cb(error);
}
}
};

SwaggerExpress.create(config, function(err, swaggerExpress) {
if (err) { throw err; }

// install middleware
swaggerExpress.register(app);

var port = Number(process.env.PORT) || 10080;
console.log('http PORT=' + port);
app.listen(port);

var https = require('https');
var fs = require('fs');
try{
var options = {
key: fs.readFileSync('./cert/server.key'),
cert: fs.readFileSync('./cert/server.crt'),
ca: fs.readFileSync('./cert/JPRS_DVCA_G2_PEM.cer')
// key: fs.readFileSync('./cert/oreore/private-key.pem'),
// cert: fs.readFileSync('./cert/oreore/certificate.pem')
};
var sport = Number(process.env.SPORT) || 10443;
var servers = https.createServer(options, app);
console.log('https PORT=' + sport );
servers.listen(sport);
}catch(error){
// console.log(error);
console.log('can not load https');
}
});


次に、静的HTMLファイルを配置するためのフォルダを作成します。

> mkdir public

もし、SSL証明書がある場合は、以下のフォルダを作成したうえで配置します。CERTファイルのファイル名は、app.jsを参照してください。

> mkdir cert

次に、以下のnpmモジュールをインストールします。

> npm install --save cors

> npm install --save dotenv
> npm install --save jwt-decode
> npm install --save express-session

dotenvを使っていますので、とりあえず中身が空の設定ファイルも作成しておきます。

> touch .env

次に、api/controllersの配下に、以下のrouting.jsを配置します。


routing.js

'use strict';

/* 関数を以下に追加する */
const func_table = {
// "test-func" : require('./test_func').handler,
// "test-dialogflow" : require('./test_dialogflow').fulfillment,
};
const alexa_table = {
// "test-alexa" : require('./test_alexa').handler,
};
const lambda_table = {
// "test-lambda" : require('./test-lambda').handler,
// "forward_lambda" : require('./forward_lambda').handler,
};
const express_table = {
// "test-clova": require('./test-clova-skill').handler,
};
/* ここまで */

/* 必要に応じて、バイナリレスポンスのContent-Typeを以下に追加する */
const binary_table = [
// 'application/octet-stream',
];
/* ここまで */

var exports_list = {};
for( var operationId in func_table ){
exports_list[operationId] = routing;
}
for( var operationId in alexa_table ){
exports_list[operationId] = routing;
}
for( var operationId in lambda_table ){
exports_list[operationId] = routing;
}
for( var operationId in express_table ){
exports_list[operationId] = express_table[operationId];
}

module.exports = exports_list;

function routing(req, res) {
// console.log(req);

var operationId = req.swagger.operation.operationId;
console.log('[' + operationId + ' calling]');

try{
var event;
var func;
if( func_table.hasOwnProperty(operationId) ){
event = {
headers: req.headers,
body: JSON.stringify(req.body),
path: req.swagger.apiPath,
httpMethod: req.method,
queryStringParameters: req.query,
requestContext: ( req.requestContext ) ? req.requestContext : {}
};

event.Host = req.hostname;

func = func_table[operationId];
res.func_type = "normal";
}else if( alexa_table.hasOwnProperty(operationId) ){
event = req.body;

func = alexa_table[operationId];
res.func_type = "alexa";
}else if( lambda_table.hasOwnProperty(operationId) ){
event = req.body.event;

func = lambda_table[operationId];
res.func_type = "lambda";
}else{
console.log('can not found operationId: ' + operationId);
return_error(res, new Error('can not found operationId'));
return;
}
res.returned = false;

// console.log(event);

var context = {
succeed: (msg) => {
console.log('succeed called');
return_response(res, msg);
},
fail: (error) => {
console.log('failed called');
return_error(res, error);
},
original_req: req
};

var task = func(event, context, (error, response) =>{
console.log('callback called');
if( error )
return_error(res, error);
else
return_response(res, response);
});
if( task instanceof Promise || (task && typeof task.then === 'function') ){
task.then(ret =>{
if( ret ){
console.log('promise is called');
return_response(res, ret);
}else{
console.log('promise return undefined');
return_none(res);
}
})
.catch(err =>{
console.log('error throwed: ' + err);
return_error(res, err);
});
}else{
console.log('return called');
// return_none(res);
}
}catch(err){
console.log('error throwed: ' + err);
return_error(res, err);
}
}

function return_none(res){
if( res.returned )
return;
else
res.returned = true;

res.statusCode = 200;
res.type('application/json');

if( res.func_type == 'alexa' ){
res.json({});
}else if(res.func_type == 'lambda'){
res.json({ body: null });
}else{
res.json({});
}
}

function return_error(res, err){
if( res.returned )
return;
else
res.returned = true;

res.status(500);
res.json({ errorMessage: err.toString() });
}

function return_response(res, ret){
if( res.returned )
return;
else
res.returned = true;

if( ret.statusCode )
res.status(ret.statusCode);
for( var key in ret.headers )
res.set(key, ret.headers[key]);

// console.log(ret.body);

if (!res.get('Content-Type'))
res.type('application/json');

if( binary_table.indexOf(res.get('Content-Type')) >= 0 ){
var bin = new Buffer(ret.body, 'base64')
res.send(bin);
}else{
if( res.func_type == 'alexa'){
res.json(ret);
}else if( res.func_type == 'lambda'){
res.json({ body: ret });
}else{
if( ret.body || ret.body == "")
res.send(ret.body);
else
res.json({});
}
}
}


api/controllers/hello.jsは使わないので削除しても問題ありません。

api/swagger/にあるSwaggerファイルは、以下に書き換えます。


swagger.yaml

swagger: '2.0'

info:
version: 'first version'
title: Lambda Laboratory Server
host: localhost:10011
basePath: /

schemes:
- http
- https

consumes:
- application/json
produces:
- application/json

securityDefinitions:
basicAuth:
type: basic
tokenAuth:
type: apiKey
in: header
name: Authorization
apikeyAuth:
type: apiKey
in: header
name: X-API-KEY
# jwtAuth:
# authorizationUrl: ""
# flow: "implicit"
# type: "oauth2"
# x-google-issuer: "https://cognito-idp.ap-northeast-1.amazonaws.com/【CognitoのプールID】"
# x-google-jwks_uri: "https://cognito-idp.ap-northeast-1.amazonaws.com/【CognitoのプールID】/.well-known/jwks.json"
# x-google-audiences: "【CognitoのアプリクライアントID】"

paths:
/swagger:
x-swagger-pipe: swagger_raw

definitions:
Empty:
type: "object"
title: "Empty Schema"

CommonRequest:
type: object
CommonResponse:
type: object


以上で完成なのですが、何かと便利なユーティリティをapi/helpers配下に配置します。


response.js

class Response{

constructor(context){
this.statusCode = 200;
this.headers = {'Access-Control-Allow-Origin' : '*'};
if( context )
this.set_body(context);
else
this.body = "{}";
}

set_error(error){
this.body = JSON.stringify({"err": error});
return this;
}

set_body(content){
this.body = JSON.stringify(content);
return this;
}

get_body(){
return JSON.parse(this.body);
}
}

module.exports = Response;



redirect.js

class Redirect{

constructor(url){
this.statusCode = 302;
this.headers = {'Location' : url};
this.body = null;
}
}

module.exports = Redirect;


以上で完成です。

public/index.htmlを作成し、起動してみましょう

> swagger project start

または

> node app.js

http://localhost:10080 にアクセスし、さきほどのindex.htmlが表示されるか確認しましょう。


ポート番号の変更方法

ポート番号は環境変数で指定可能です。

.envに以下を指定します。


.env

PORT=80HTTPのポート番号

SPORT=443HTTPSのポート番号


エンドポイントの追加

それでは早速、Swagger定義ファイルにエンドポイントを追加して、実体を実装してみます。

まずは、GETの場合。

例えば、以下を追記します。


swagger.yaml

・・・

/test-get:
get:
x-swagger-router-controller: routing
operationId: test-get
responses:
200:
description: Success
schema:
$ref: "#/definitions/CommonResponse"
・・・

実体を実装します。

> mkdir api/controllers/test-get

> vi api/controllers/test-get/index.js

例えばこんな感じです。


index.js

'use strict';

const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const Response = require(HELPER_BASE + 'response');
const Redirect = require(HELPER_BASE + 'redirect');

exports.handler = async (event, context, callback) => {
console.log(event.queryStringParameters);
return new Response({ message: 'GET : Hello World' });
};


route.jsのfunc_table の部分に、実体を実装するコードの場所を指定します。


routing.js

const func_table = {

// "test-func" : require('./test_func').handler,
"test-get" : require('./test-get').handler,
};

"test-get" : ・・・ のところには、swagger定義ファイルに指定したoperationIdに指定した値を指定します。

これにより、operationIdで指定したエンドポイント「/test-get」への呼び出しが、api/controllers/test-get/index.js に処理が渡されます。

次に、POSTの場合。


swagger.js

・・・

/test-post:
post:
x-swagger-router-controller: routing
operationId: test-post
parameters:
- in: body
name: body
schema:
$ref: "#/definitions/CommonRequest"
responses:
200:
description: Success
schema:
$ref: "#/definitions/CommonResponse"
・・・

(ちなみに、CommonRequestやCommonResponseとしているのは、これ自体あまり意味はないのですが、こうしておくことで、API Gatewayに上げたときに、無名のモデルができるてしまうのを避けるためです)

それでは実体を実装します。

> mkdir api/controllers/test-post

> vi api/controllers/test-post/index.js

例えばこんな感じです。


index.js

'use strict';

const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const Response = require(HELPER_BASE + 'response');
const Redirect = require(HELPER_BASE + 'redirect');

exports.handler = async (event, context, callback) => {
var body = JSON.parse(event.body);
console.log(body);
return new Response({ message: 'POST : Hello World' });
};


実体を実装するコードの場所を指定します。


routing.js

const func_table = {

// "test-func" : require('./test_func').handler,
"test-get" : require('./test-get').handler,
"test-post" : require('./test-post').handler,
};

実体の場所をtest-getとtest-postで同じにすることもできます。

その場合は、event.handlerの中で、event.pathにエンドポイント名が入っていますので、処理分岐するようにします。

作成したフォルダを、Visual Studio Codeで開けば、ブレイクポイントを入れたりして、console.logが出力されたりと、簡単にデバッグできることがわかると思います。

image.png

この状態で、しっかり実装・デバッグしておきましょう。


AWS LambdaにHelperライブラリのLayerを追加

AWS Lambdaに配備していくのですがその前に、複数のLambdaで共通利用するHelperライブラリを配備しておきます。

response.jsやredirect.jsは、共通的に使うため、AWS LambdaのLayer機能を使って、Lambdaでも共通化しましょう。

一度上げておけば、LambdaのコードをアップするごとにHelperライブラリを挙げる必要がなくなります。

(このLayerという機能は最近追加された機能で、大変便利ですので、どんどん活用しましょう。)

まずは、共通利用したいファイル(response.jsとredirect.js)をまとめてZIP化します。

そして、AWS管理コンソールからLambdaを開き、左側のナビゲータからLayerを選択します。

image.png

「Layerの作成」ボタンを押下します。

image.png

名前は何でもよいですが、例えば、「helpers」とします。

先ほどZIP化したファイルを指定し、互換性のあるランタイムとして「Node.js 8.10」を選択し、最後に「作成」ボタンを押下します。

これで、複数のLambdaで共用できるようになりました。

(成功した後に表示されるバージョン番号は、同じ名前でアップロードした回数に依存します)


AWS Lambdaにコードを配置

それではいよいよ完成したコードをAWS Lambdaに配備してみましょう。

AWS Lambdaの管理コンソールから、Lambda関数を作成します。

ランタイムは、Node.js 8.10を選択します。

image.png

関数コードには、index.jsの中身をコピペします。

次に、HelperライブラリのLayerを追加します。DesignerのところにあるLayerを選択して、追加したhelperを選択します。

image.png

また、環境変数のところに、HELPER_BASEを追加し、値として、「/opt/」を指定します。

(Layer追加したライブラリはこのディレクトリに配置されるためです)

image.png

「test-post」も同様に実施します。


API GatewayにSwagger定義を追加

次に、API GatewayのWeb管理コンソールを開きます。

そして、「+APIの作成」ボタンを押下します。

新しいAPIの作成として、「Swagger からインポート」を選択します。

そして、swagger.yamlをコピペしましょう。

image.png

「インポート」ボタンを押下します。

警告が出ますが、気にせず、「インポートして警告を無視する」ボタンを押下してインポートを進めます。

image.png

(Swagger定義ファイルに指定したinfo:titleがそのままAPIの名前になるため、変える場合はSwagger定義ファイルを変更してからインポートしてください)

/swaggerは、AWS Lambdaでは動作しないため、削除します。「リソースの削除」で削除できます。

次に、各エンドポイントにLambdaを割り当てます。

ここで、忘れずに「Lambdaプロキシの統合を使用」のチェックボックスはOnにします。

image.png

もう一つ忘れずに、「CORSの有効化」をしておきましょう。

image.png

「test-post」も同様に実施します。

最後に、APIのデプロイをします。ステージ名は例えば「v1」とします。

image.png

特に問題がなければ、「URLの呼び出し」のところにURLが割り当てられているのがわかります。

動作確認には、POSTMANが使いやすいです。

さきほどのURLに、「test-get」を付けてGET呼び出し、「test-post」を付けてPOST呼び出しをしてみてください。

無事に、レスポンスが返ってくれば成功です。

以上です。