前回の続きです。IBM Bluemix に独自のカスタムサービスを追加する手順を紹介しています:
IBM Bluemix にカスタムサービスを追加する(1/3)

前回紹介した Node.js で実装済みの REST API を IBM Bluemix のカスタムサービスとして利用できるように改良する、その改良内容を紹介します。


前回のおさらいになりますが、改良前の API はこちらです:
https://github.com/dotnsf/dotnsfOperation/blob/master/app0.js
//. app0.js
var express = require( 'express' ),
    cfenv = require( 'cfenv' ),
    appEnv = cfenv.getAppEnv(),
    app = express();


//. Service APIs
app.get( '/', function( req, res ){
  var html = '<title>My Broker</title>';
  html += '<h1>My Broker</h1>';
  html += '<p>ドキュメントは<a target="_blank" href="./doc">こちら</a>を参照ください</p>';
  res.writeHead( 200, [ { 'Content-Type': 'text/html; charset=UTF8' } ] );
  res.write( html );
  res.end();
});

app.get( '/:operation/:x/:y', function( req, res ){
  var operation = req.params.operation;
  var x = parseFloat( req.params.x );
  var y = parseFloat( req.params.y );
  var z = 0;

  if( operation == 'plus' ){
    z = x + y;
  }else if( operation == 'minus' ){
    z = x - y;
  }else if( operation == 'multiply' ){
    z = x * y;
  }else if( operation == 'divide' ){
    z = x / y;
  }

  res.writeHead( 200, [ { 'Content-Type': 'text/plain' } ] );
  res.write( '' + z );
  res.end();
});

app.get( '/doc', function( req, res ){
  var html = '<title>My Operation Document</title>';
  html += '<h1>My Operation Document</h1>';
  html += '<p>This is a document for my operation</p>';
  html += '<hr/>';
  html += '/plus/x/y => return ( x + y )<br/>';
  html += '/minus/x/y => return ( x - y )<br/>';
  html += '/multiply/x/y => return ( x + y )<br/>';
  html += '/divide/x/y => return ( x / y )<br/>';
  res.writeHead( 200, [ { 'Content-Type': 'text/html; charset=UTF8' } ] );
  res.write( html );
  res.end();
});

var service_port = appEnv.port ? appEnv.port : 1337;
app.listen( service_port );
console.log( "server starting on " + service_port );


最終型を先に紹介しますが、これを以下のように改良します(追加箇所を赤くしています):
https://github.com/dotnsf/dotnsfOperation/blob/master/app.js
//. app.js
var express = require( 'express' ),
    basicAuth = require( 'basic-auth-connect' ),
    bodyParser = require( 'body-parser' ),
    cfenv = require( 'cfenv' ),
    uuid = require( 'uuid' ),
    appEnv = cfenv.getAppEnv(),
    app = express();

app.use( bodyParser.urlencoded( {
    extended: true
}));
app.use( bodyParser.json() );

//. API Version
const X_BROKER_API_VERSION = 2.0;
const X_BROKER_API_VERSION_NAME = 'X-Broker-Api-Version';

//. Plans
const plan0 = {
  'id': uuid.v4(),
  'name': 'free',
  'description': 'Free plan',
  'free': true
};
const plan1 = {
  'id': uuid.v4(),
  'name': 'enterprise',
  'description': 'Enterprise plan',
  'free': false
};

//. service endpoint
var service_host = 'dotnsf-operation.mybluemix.net';
var service_port = appEnv.port ? appEnv.port : 1337;
var service_base = service_host; // + ':' + service_port;
var service_instance = 'http://' + service_base + '/operation/';
var service_dashboard = 'http://' + service_base + '/operation/dashboard';

//. Service
var my_service_id = uuid.v4();
var my_service = {
  'id': my_service_id,
  'name': 'dotnsf-operation-service',
  'description': '四則演算 API',
  'bindable': true,
  'tags': [ 'private' ],
  'plans': [ plan0, plan1 ],
  'dashboard_client': {
    'id': uuid.v4(),
    'secret': 'secret-1',
    'redirect_uri': 'http://bluemix.net'
  },
  'metadata': {
    'displayName': 'My operational service',
    'longDescription': 'WebAPI で四則演算を行う',
    'providerDisplayName': 'dotnsf',
    'documenttationUrl': 'http://' + service_base + '/doc',
    'supportUrl': 'https://stackoverflow.com/questions/tagged/ibm-bluemix'
  }
};


//. Auth for /v2/*
const auth_user = 'username';
const auth_pass = 'password';
app.all( '/v2/*', basicAuth( function( username, password ){
  return username === auth_user && password === auth_pass;
}));


//. Cloud Foundry Broker APIs

//. Catalog
app.get( '/v2/catalog', function( req, res ){
  console.log( 'GET /v2/catalog' );

  var api_version = req.get( X_BROKER_API_VERSION_NAME );
  if( !api_version || X_BROKER_API_VERSION > parseFloat( api_version ) ){
    res.writeHead( 412, [ { 'Content-Type': 'text/plain' } ] );
    res.write( 'Precondition failed. Missing or imcompatible ' + X_BROKER_API_VERSION_NAME + '. Expecting version ' + X_BROKER_API_VERSION + ' or later.' );
    res.end();
  }else{
    var services = {};
    services['services'] =[];
    services['services'][0] = my_service;
    res.writeHead( 200, [ { 'Content-Type': 'application/json' } ] );
    res.write( JSON.stringify( services ) );
    res.end();
  }
});

//. Provision
app.put( '/v2/service_instances/:instance_id', function( req, res ){
  var instance_id = req.params.instance_id;

  var contentType = req.get( 'Content-Type' );
  if( contentType != 'application/json' ){
    res.writeHead( 415, [ { 'Content-Type': 'text/plain' } ] );
    res.write( 'Unsupported Content-Type: expecting application/json.' );
    res.end();
  }

  var provision_details = req.body;
  //. {
  //    "service_id": "<service-guid>",
  //    "plan_id": "<plan-guid>",
  //    "organization_guid": "<org-guid>",
  //    "space_guid": "<space-guid>"
  //  }
  console.log( 'PUT /v2/service_instances/' + instance_id );
  console.log( provision_details );

  //. Provisioning process here.

  //. Return basic service information
  var new_service = {};
  //new_service['dashboard_url'] = service_dashboard + '/' + instance_id;
  new_service['dashboard_url'] = service_dashboard + '/' + instance_id;
  res.writeHead( 200, [ { 'Content-Type': 'application/json' } ] );
  res.write( JSON.stringify( new_service ) );
  res.end();
});


//. Deprovision
app.delete( '/v2/service_instances/:service_id', function( req, res ){
  var service_id = req.params.service_id;

  console.log( 'DELETE /v2/service_instances/' + service_id );

  //. Deprovisioning process here.

  //. Return basic information
  res.writeHead( 200, [ { 'Content-Type': 'application/json' } ] );
  res.write( JSON.stringify( {} ) );
  res.end();
});

//. Bind
app.put( '/v2/service_instances/:service_id/service_bindings/:binding_id', function( req, res ){
  var service_id = req.params.service_id;
  var binding_id = req.params.binding_id;

  var contentType = req.get( 'Content-Type' );
  if( contentType != 'application/json' ){
    res.writeHead( 415, [ { 'Content-Type': 'text/plain' } ] );
    res.write( 'Unsupported Content-Type: expecting application/json.' );
    res.end();
  }

  var binding_details = req.body;
  //. {
  //    "plan_id": "<plan-guid>",
  //    "service_id": "<service-guid>",
  //    "app_guid": "<app-guid>"
  //  }
  console.log( 'PUT /v2/service_instances/' + service_id + '/service_bindings/' + binding_id );
  console.log( binding_details );

  //. Binding process here.

  //. Return result to the Bluemix Cloud Controller
  var result = {};
  result['credentials'] = {};
  result['credentials']['uri'] = 'http://' +  service_base + '/';
  res.writeHead( 200, [ { 'Content-Type': 'application/json' } ] );
  res.write( JSON.stringify( result ) );
  res.end();
});

//. Unbind
app.delete( '/v2/service_instances/:service_id/service_bindings/:binding_id', function( req, res ){
  var service_id = req.params.service_id;
  var binding_id = req.params.binding_id;

  console.log( 'DELETE /v2/service_instances/' + service_id + '/service_bindings/' + binding_id );

  //. Unbinding process here.

  //. Return basic information
  res.writeHead( 200, [ { 'Content-Type': 'application/json' } ] );
  res.write( JSON.stringify( {} ) );
  res.end();
});



//. Service related functions
app.all( '/operation/:instance_id', function( req, res ){
  var instance_id = req.params.instance_id;

  console.log( req.method + ' /operation/' + instance_id );

  //. Return service information
  var service_info = {};
  service_info['greeting'] = instance_id;
  res.writeHead( 200, [ { 'Content-Type': 'application/json' } ] );
  res.write( JSON.stringify( service_info ) );
  res.end();
});

app.get( '/operation/dashboard/:instance_id', function( req, res ){
  var instance_id = req.params.instance_id;

  console.log( req.method + ' /operation/' + instance_id );

  //. Return hard-coded HTML
  var html = '見つかっちゃった。。。(*/ω\*)';
  res.writeHead( 200, [ { 'Content-Type': 'text/html; charset=UTF8' } ] );
  res.write( html );
  res.end();
});

app.all( '/operation/:instance_id/:binding_id', function( req, res ){
  var instance_id = req.params.instance_id;
  var binding_id = req.params.binding_id;

  var contentType = req.get( 'Content-Type' );
  if( contentType != 'application/json' ){
    res.writeHead( 415, [ { 'Content-Type': 'text/plain' } ] );
    res.write( 'Unsupported Content-Type: expecting application/json.' );
    res.end();
  }

  //. Return service information
  var service_info = {};
  service_info['instance_id'] = instance_id;
  service_info['binding_id'] = binding_id;
  res.writeHead( 200, [ { 'Content-Type': 'application/json' } ] );
  res.write( JSON.stringify( service_info ) );
  res.end();
});



//. Service APIs
app.get( '/', function( req, res ){
  var html = '<title>My Broker</title>';
  html += '<h1>My Broker</h1>';
  html += '<p>ドキュメントは<a target="_blank" href="./doc">こちら</a>を参照ください</p>';
  res.writeHead( 200, [ { 'Content-Type': 'text/html; charset=UTF8' } ] );
  res.write( html );
  res.end();
});

app.get( '/:operation/:x/:y', function( req, res ){
  var operation = req.params.operation;
  var x = parseFloat( req.params.x );
  var y = parseFloat( req.params.y );
  var z = 0;

  if( operation == 'plus' ){
    z = x + y;
  }else if( operation == 'minus' ){
    z = x - y;
  }else if( operation == 'multiply' ){
    z = x * y;
  }else if( operation == 'divide' ){
    z = x / y;
  }

  res.writeHead( 200, [ { 'Content-Type': 'text/plain' } ] );
  res.write( '' + z );
  res.end();
});

app.get( '/doc', function( req, res ){
  var html = '<title>My Operation Document</title>';
  html += '<h1>My Operation Document</h1>';
  html += '<p>This is a document for my operation</p>';
  html += '<hr/>';
  html += '/plus/x/y => return ( x + y )<br/>';
  html += '/minus/x/y => return ( x - y )<br/>';
  html += '/multiply/x/y => return ( x + y )<br/>';
  html += '/divide/x/y => return ( x / y )<br/>';
  res.writeHead( 200, [ { 'Content-Type': 'text/html; charset=UTF8' } ] );
  res.write( html );
  res.end();
});

app.listen( service_port );
console.log( "server starting on " + service_port );

追加した箇所をで記載しています。通常の Web API を IBM Bluemix(Cloud Foundry) サービスとして対応させる場合には少なくともこれだけの追加が必要になる、と理解してください。

具体的な追加の内容は8種類12個の Web API を追加しています。以下にそれぞれの内容を紹介します。

(1) Catalog API (GET /v2/catalog)

(コマンドライン版の)サービス一覧に表示される情報を取得する API です。リクエスト時に HTTP ヘッダ 'X-Broker-Api-Version' が設定されており、その値が実装 API バージョン(この場合は 2)以上でない場合はエラーとします。実行後の返り値についてはソースコード内の変数: services 参照していただきたいのですが、返り値のオブジェクトの中にサービス名称や説明、選択可能なプランや資料情報 URL などを含めます。


(2) Provision API (PUT /v2/service_instances/:instance_id)

サービスをプロビジョンして作成する際にコールされる API です。HTTP ヘッダ 'Content-Type' の値が 'application/json' 以外の場合はエラーとします。実際にはパラメータ :instance_id の値を使ってインスタンスを生成して管理するところまでを実装する必要があるのですが、今回のサンプル内ではそこまでは実装していません。実行後の返り値についてはソースコード内の変数: new_service を参照してください。


(3) Deprovision API (DELETE /v2/service_instances/:instance_id)

サービスでデプロビジョンして削除する際にコールされる API です。実際にはパラメータ :instance_id の値を使って管理中のインスタンス情報を削除するところまでを実装する必要があるのですが、今回のサンプル内ではそこまでは実装していません。実行後の返り値は空オブジェクトです。


(4) Bind API (PUT /v2/service_instances/:instance_id/service_bindings/:binding_id)

サービスのバインドを作成する際にコールされる API。HTTP ヘッダ 'Content-Type' の値が 'application/json' 以外の場合はエラーとします。実際にはパラメータ :instance_id , :binding_id の値を使ってバインドを生成して管理するところまでを実装する必要があるのですが、今回のサンプル内ではそこまでは実装していません。実行後の返り値の中にランタイムの環境変数 VCAP_SERVICES に含まれる credentials 情報を含めます(つまり実行時用の id や password が必要な場合は、ここで作成して返り値に含める、という実装を行ってください)。具体的にはソースコード内の変数: result を参照してください。


(5) Unbind API (DELETE /v2/service_instances/:instance_id/service_bindings/:binding_id)

サービスをアンバインドして削除する際にコールされる API です。実際にはパラメータ :instance_id , :binding_id の値を使って管理中のバインド情報を削除する(例えば (4) で生成した username と password を無効にする、など)ところまでを実装する必要があるのですが、今回のサンプル内ではそこまでは実装していません。実行後の返り値は空オブジェクトです。


↑上記 (1) ~ (5) の API 実行時には Basic 認証をかけておきます。


(6) Instance Info APIs (PUT,GET,DELETE /operation/:instance_id)

サービスインスタンス情報を作成(PUT)/取得(GET)/削除(DELETE)するための API です。具体的な返り値はコースコード内の変数: service_info を参照してください。


(7) Bind Info APIs (PUT,GET,DELETE /operatoin/:instance_id/:binding_id)

サービスインスタンスとのバインド情報を作成(PUT)/取得(GET)/削除(DELETE)するための API です。HTTP ヘッダ 'Content-Type' の値が 'application/json' 以外の場合はエラーとします。具体的な返り値はコースコード内の変数: service_info を参照してください。


(8) Dashboard (GET /operation/dashboard/:instance_id)

サービスの管理情報やダッシュボードとして表示する画面です。IBM Bluemix のウェブ画面に表示される内容です:
2017021401

  ↑この「管理」タブの内容です

なお、この中身が正しく表示されるためには https でこの API が実行できる必要があります。具体的な返り値の例はコースコード内の変数: html を参照してください。



元のコード(app0.js)に、上記8つの API を追加実装することで IBM Bluemix のサービスとしての最低限の振る舞いが可能になります。また (1) ~ (5) の API については認証を付けることができるので、そのための実装も追加したものが新しいコード(app.js)、ということになります。この追加実装のためにいくつかのミドルウェアも追加で必要になったため、新しいコードではその部分も含めて追加しています。


そして、パッケージ情報を記述したファイル(packages.json)や、IBM Bluemix 上で実行するための manifest.ymlREADME.md までを追加した一通りのセットがこちらです:
https://github.com/dotnsf/dotnsfOperation

2017021402


IBM Bluemix 環境で動かす場合の使い方は README.md にも記載していますが、ダウンロードするか git clone した後に manifest.yml 内のアプリ名、ホスト名、ドメイン名をランタイム環境に合わせて編集します。加えて app.js 内に "dotnsf-operation.mybluemix.net" というホスト上で、"dotnsf-operatoin-service" というサービス名で動かすことを前提とした内容が記述されているので、該当箇所を実際の内容に合わせて書き換えます。また Basic 認証は username : password となっているので、ここを変更する場合はコード内の auth_user および auth_pass 変数を書き換えてください。

こうしてコードを追加した app.js を IBM Bluemix にプッシュすることでサービスが稼働することになります。なお、サービスそのものは IBM Bluemix 内で稼働させる必要はありませんが、上記 (8) の API は https 対応している必要があるので、IBM Bluemix 以外の環境で動かす場合は https 対応できる環境下で稼働させてください。


実際に dotnsf-operation.mybluemix.net 上にプッシュして動かしている様子が以下になります:
2017021402
 ↑元々の機能+αが実装された API を動かしています。

これで IBM Bluemix のサービスとして統合可能な Web API の用意ができました。次回は実際にこの API を IBM Bluemix のカスタムサービスとして登録し、ランタイムにバインドして使ってみる様子とその手順を紹介します。


(追記)
続きはこちら:
http://dotnsf.blog.jp/archives/1064290977.html