まだプログラマーですが何か?

プログラマーネタとアスリートネタ中心。たまに作成したウェブサービス関連の話も http://twitter.com/dotnsf

タグ:rest

前2回の続きです:
IBM Bluemix にカスマムサービスを追加する(1/3)
IBM Bluemix にカスマムサービスを追加する(2/3)


前回作成した REST API を実際に IBM Bluemix のカスタムサービスとして登録し、利用するまで(正確には使用を終えてカスタムサービスから削除するところまで)の手順を紹介します。なおこの手順はコマンドラインツールである cf が必要になるので、インストールしていない場合は各自の環境にあった cf をあらかじめダウンロード&インストールしておいてください:
https://github.com/cloudfoundry/cli/releases


また、今回カスタムサービスとして登録する REST API は前回紹介した app.js が https://dotnsf-operation.mybluemix.net/ にデプロイされて動いているものとします。以下の説明でのこのホスト名部分を実際にみなさんがデプロイしたホスト名に合わせて読み替えてください:
2017021401

2017021401


まず最初に、以下で登録するカスタムサービスをバインドして動作確認するためのランタイムを用意しておきます。動作確認用のアプリケーション(後述)を PHP で用意したので、IBM Bluemix 上に PHP のランタイムを1つ作成します(以下の例では teyande-php-env という名前で作成しています)、なおこの段階ではサービスは何もバインドしないでください:
2017021402


このランタイムに以下の内容の PHP アプリケーションをプッシュします:
https://github.com/dotnsf/phpEnv


上記アプリケーション(index.php)はシンプルに「ランタイムサーバー上の全環境変数とその値を表示する」というアプリケーションです。プッシュ後にサーバーにアクセスすると以下のような画面になります。この時点ではサービスを1つもバインドしていないので、環境変数 VCAP_SERVICES の値は空オブジェクトを示す "{}" となっていることが確認できるはずです:
2017021403


ここまでで準備は完了です。では前回紹介したサービスを IBM Bluemix のカスタムサービスとして登録した上でインスタンス化し、このランタイムにバインドした上で、この環境変数がどのように変わるかを確認してみます。

コマンドプロンプトかターミナルを起動し、上記の PHP ランタイムと同じデータセンター(この場合は US-SOUTH)の自分の組織に cf コマンドでログインします:
2017021404


カスタムサービスを利用するにためには Cloud Foundry の「サービスブローカー」(カスタムカタログのように理解してください)に登録しておく必要があります。まず現在のサービスブローカー一覧を確認するため "cf service-brokers" コマンドを実行して、この時点では何も登録されていないことを確認します:
2017021301
 ↑"No service brokers found"(何も登録されていません)です


ではサービスブローカーを新規に1つ作成します。以下のパラメータを付けてコマンドを実行します:
> cf create-service-broker サービスブローカー名 認証ユーザー名 認証パスワード サービスの基本URL --space-scoped
2017021302


今回、サービスブローカー名は "dotnsf-operation" とします。認証のユーザー名とパスワードは dotnsf-operation の Catalog API などが実行される時用に指定した auth_user / auth_pass の値を指定します(詳しくは前回の記事参照)。サービスの基本 URL は今回 "https://dotnsf-operation.mybluemix.net" です。そして最後にオプションの "--space-scoped"(同一組織内のユーザーが使えるサービスとするための指定)を加えています。

コマンドが成功したことを確認し、再度 "cf service-brokers" を実行してみます。先程は結果に何も含まれていませんでしたが、今度は直前に作成したばかりの "dotnsf-operation" サービスブローカーが、指定した URL で登録されていることを確認してください:
2017021303


これで dotnsf-operation API がカスタムカタログに登録された状態になりました。ただ IBM Bluemix のウェブ UI ではカスタムカタログを表示する画面が存在していないので、この時点ではまだウェブ UI には現れませんが、cf からは同様にインスタンスを作成することができるようになっています。

というわけで、続けて cf でこのサービスをインスタンス化してみます:
> cf create-service サービスブローカー名 プラン名 サービス名

サービスブローカー名は dotnsf-operation の Catalog API(GET /v2/catalog) 内で定義したもの(今回の場合は dotnsf-operation-service)を指定します。プランも同様に Catalog API 内で定義したもの(今回は "free" と "enterprise" の2つ)の中から指定できますが、今回は "free" を指定しています。最後に IBM Bluemix 内でのサービス名として "dotnsf-operation-1" と指定しています:
2017021304


ここまでの手続きが完了すると、dotnsf-operation サービスがインスタンス化され、ウェブ UI の利用中サービス一覧の中にも表示されるようになります:
2017021301


この時点でサービスを選択し、管理タブを開くと Dashboard API で定義した内容が表示されるはずです(https で Dashboard API にアクセスできることが条件です):
2017021000


ではこのサービスを最初に作成した PHP ランタイムにバインドしてみましょう。PHP ランタイムの画面に移動し、接続タブの「既存に接続」をクリックします:
2017021302


現在利用中のサービスインスタンスの一覧が表示されます。その中に "dotnsf-operation-1" という名前のサービスが含まれているはずなので、これを選択して「接続」をクリックします。そしてこの内容をランタイムの環境変数にも反映させるため「再ステージング」します:
2017021303


改めて同ランタイムの接続タブを参照すると、"dotnsf-operation-1" サービスが追加されていることを確認できます。ここで「資格情報の表示」をクリックします:
2017021304


今回の dotnsf-service の Bind API では credentials 情報として uri だけを追加していました。そのためこのサービスインスタンスの資格情報にも uri 1つだけが credentials 情報として表示されていることが確認できるはずです:
2017021305


改めてランタイムのトップ画面にアクセス(リロード)します。今回は先程と異なり dotnsf-operation-1 サービスがバインドされているので、環境変数 VCAP_SERVICES は空オブジェクトではなく、dotnsf-operation-1 の情報が表示されているはずです。credentials 情報も先程確認したものと同じものが表示されています:
2017021306



・・・というわけで、IBM Bluemix のカスタムサービスを作成して、登録して、インスタンス化して、ランタイムとバインドして環境変数に反映させる、という一連の作業ができることを確認できました!

最後に使い終わったサービスブローカーを削除する手順を紹介しておきます。まず(同サービスブローカーを使っているランタイムからのバインドを全て解除した上で)サービス一覧画面からインスタンス化されたサービスを削除します:
2017021305


サービスが削除されるとウェブ UI からは見えなくなります。が、まだサービスブローカーには登録されているので、以下の cf コマンドでサービスブローカーからも削除します:
> cf delete-service-broker サービスブローカー名
2017021306


このコマンドが成功すると、サービスブローカー一覧からも削除されます。念のため "cf service-brokers" コマンドで確認します:
2017021307




久しぶりの超大作ブログとなり、3回に分けて紹介しましたが、IBM Bluemix のカスタムサービスを作るために実装しないといけない内容や、実装後の登録方法などを紹介できたつもりです。自分が作った REST API を IBM Bluemix に統合して使いたい場合や、公式なサードパーティ API としての登録を検討する場合に必要となる実装がどんなものかを説明しました。

今回紹介した内容はあくまで「同一組織内でのみ有効なカスタムサービス」の登録方法および手順の紹介でしたが、全 IBM Bluemix ユーザー向けに実装する場合であっても同様の API 実装は必要になります。そういったエコシステムを活性化する上での役立つ情報になれば幸いです。


(参考)
 https://www.ibm.com/blogs/bluemix/2017/01/extend-bluemix-service-broker/

前回の続きです。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

とある REST API を使っていて気付いたこと/考えさせられたことをまとめてみました。明快な結論や提案があるわけではなく、グダグダに感じられる内容かもしれないので、あらかじめご了承ください。


そのとある REST API を使ったウェブアプリケーションを作って運用している中で、おかしな挙動に気付くことがありました。以前は問題なく動いていたのに、あるタイミングで実行すると期待通りに動かない、という「まあまあよくある」ケースです。自分のケースでは動かないというよりも、タイムアウトを起こすような挙動になっていました。ただ REST API そのものが止まっている様子はない、というケースでした。

自分のソースコードでは一応エラーハンドリングはしていたつもりでしたが、REST API がエラーを起こしている様子もなく、原因究明に時間を要するものでした。結論としては自分のアプリケーションコードを見ていてもよく分からず、REST API のユニットテストのようなツールを動かした結果気付くことがありました。

それがこちら:
20170207


・・・わかるでしょうか? REST API を実行したレスポンス本文が Response Body に、ステータスコードが Response Code に記述されています。これによるとレスポンス本文は
{
  "status": "ERROR",
  "statusInfo": "daily-transaction-limit-exceeded"
}

となっていて、"daily-transaction-limit-exceeded" が原因のエラーが発生している、という内容でした。この API には Daily Transaction Limit(1日で使える回数の上限)が決められていて、その上限に達したのでもう実行できない、という内容です。これに関してはそういう条件で使っている API なので、なるほど、エラーの原因はわかりました。

問題はこの REST API を実行したステータスコードが 200 になっている点です。HTTP ステータスコードの分類とその意味についてはウィキペディアなどを参照していただきたいのですが、簡単にいうとこんな感じで分類されています:
コード意味考えられる原因など
2xx(200番台)成功 -
4xx(400番台)クライアントエラー認証が必要、アクセス権がない、URLが間違っている、タイムアウト、、、
5xx(500番台)サーバーエラーアプリケーションエラー、不正なゲートウェイが利用されている、、、


200 番台は成功。400 番台と 500 番台がエラーで、それぞれクライアント側のエラーなのか、サーバー側のエラーなのかを分類しています。

で、今回のケースですが、実行結果はエラーなのに 200 番のステータスコードが返されているのでした。自分のプログラムコードの中では「200 が返ってきたら成功」と決めつけて実装していたため、このようなケースに対処できていなかったのでした。


ここまでが実際に目の当たりにしたエラーとその原因でした。以下はこの件に関して自分が考えたことです。


自分を弁護する意味で「えー、でもそれっておかしくない? エラーなんだからスタータスコードは 400 番台なり 500 番台で返ってくるべきでは?」という考えもないわけではありません。ただ今回のケースではこれも難しいような、つまり 200 番のステータスコードがあながち間違ってはいないような気もしています。

その理由として、まず「これはクライアントエラーなのか?それともサーバーエラーなのか?どちらかに分類できるのか?」という問題です。これに関してはどちらとも言えるし、どちらとも言えないと思っています。

実は 402 番のステータスコードは "Payment Required" 、つまり(お金を払わないといけないんだけど)払ってないエラー、と定義されています。が、実際には定義だけで実装されていない、つまり将来のための定義とされているのでした。また厳密には支払い契約を結んで使っているわけではなく、「この条件で1日○○回使える」というルールの下で利用しているだけなので、402 番に(クライアントエラーに)該当するエラーであるとは考えにくいのです。

※本来の意味とは違いますが、「権限がない」に該当するのではないかと言われると、まあ・・・ という考え方もあるとは思います。ただそれをクライアントエラーとして返してもクライアント側はどうにもできないので、やはり 400 番台エラーには該当しないと思います。

ではサーバーエラーなのか?というと、これも該当しないと思います。プログラミングにミスがあったわけではなく、「実行したら、『実行できない』という結果が返ってきた」のは、正しく実行された(結果が期待通りではなかった)とも言えます。そう考えると、そもそも今回の件は REST API レベルではエラーですらないとも言えます。


そう考えると、今のように API を組み合わせてアプリケーションを作ることが珍しくない環境においては、200 番のステータスコードが返ってきてもエラーの可能性を疑ってコーディングする必要があるのかも、と思うようになりました。このケースであれば実装側が工夫すれば(というか、そもそもちゃんと色んなケースを想定してエラーハンドリングしていれば)防げるものです。

そういう意味でもいい反省の機会でした。 でも今後は同様のケースを想定した「成功でもエラーでもないステータス」が出て来る可能性もありますよね。。


メール送信(送信だけじゃないけど)サービスの最大手と思われる SendGrid を使う機会がありました。SDK や API を使って、メールを送信できるサービスです。
2016090601


メール送信そのものは(sendmail などを使えば)単純に実現できますが、一度に大量のメールを送る場合や迷惑メール対策など、本格的に使おうとすると色々な面倒が待っています。そういう面倒な所を一手に引き受けて、心配なくメール送信が実現できる、というサービスです。

この SendGrid によるメール送信ですが、ダッシュボード画面から目的の相手にインタラクティブに送信するだけではなく、プログラミングのためのインターフェースが公開されているので、自分の作るアプリケーションから利用することもできます。ただ実際に送信するサンプルを Java でググると、多くの場合 SDK を使ったものが見つかります。自分は SDK ではなく Web API(REST API) での実現を考えていたので挑戦してみました。ウェブ上にあまり資料がなかったこともあって、その手順を以下に紹介します。


まず最初に大事なこと。SendGrid Web API の最新バージョンは V3 ですが、V2 を使います。SendGrid の推奨でもあります:
https://support.sendgrid.kke.co.jp/hc/ja/articles/206231901-Web API v2とv3どちらを利用すべきでしょうか?-


というわけで、Web API V2 の Mail API を使って実装することにします。リファレンスはこちらです:
https://sendgrid.kke.co.jp/docs/API_Reference/Web_API/mail.html


これを読むと、「メールを送信して結果を JSON で受け取る」場合は以下の様な REST API を実行することになります(これは curl コマンドで実行する場合の例):
$ curl -X POST https://api.sendgrid.com/api/mail.send.json -H 'Authorization:Bearer SG.*****' -d 'to=user1@recipient.com&subject=abcd&from=info@from.com&html=<b>ハロー</b>、ワールド'

上記コマンドは info@from.com ユーザーから user1@recipient.com ユーザーへ、メールサブジェクトは "abcd" 、本文は HTML で "<b>ハロー</b>、ワールド"(HTML として送信しているので、ハローだけが太字になります)を送信する場合のコマンドになります。 user1@recipient.com は送信先なので実在している必要がありますが、送信アドレスである info@from.com は実在している必要はありません。またヘッダの認証情報として使っている SG. で始まる文字列は SendGrid から取得した api key です。SendGrid のアカウントをお持ちで、まだ api key を取得していない場合、取得方法についてはこちらを参照してください。

上記 curl コマンドは(入力ミスなどがなければ)正しく実行されて、メールも送信されるはずです。つまり正しいコマンドです。これを REST API と見なして、同じ処理を Java で実装しなおせばいいわけですが、これが意外と手間取りました。

まず最初に、普段使っている Jakarta Commons HTTP Client 3.1 (メンテナンスモード)を使って、こんなコードを書いてみました(あらかじめ書いておきますが、以下のコードでは期待通りに動きません):
  :
public int sendMail( String to, String subject, String html ){
  int r = 0;

  try{
    String data = "to=" + to + "&from=info@from.com&subject=" + subject + "&html=" + html;
    PostMethod method = new PostMethod( "https://api.sendgrid.com/api/mail.send.json" );
    method.setRequestHeader( "Authorization", "Bearer SG.*****" );
    method.setRequestBody( data );
    HttpClient client = new HttpClient();
    int sc = client.executeMethod( method );
    String json = method.getResponseBodyAsString();
      :
r = 1; }catch( Exception e ){ mothod.setRequest e.printStackTrace();
r = -1; } return r; } :

オプションで指定している内容は curl コマンドのものと同じです(例えば Content-Type ヘッダを指定していませんが、curl でも指定せずに動いていたので)。ただこの sendMail 関数を to = user1@recipient.com, subject =  abcd, html = <b>ハロー</b>、ワールド というパラメータで実行した場合、ステータスコード(上記コード内の sc 変数)の値は 400 となり、また実行結果である json 変数の値は以下のようなものになりました:
{"errors":["Empty from email address (required)"],"message":"error"}

「必須項目である from 値が空である」というエラーメッセージのように見えます。しかしその値は上記のようにハードコーディングで入力しているつもりでした。

これまでも HTTP Client を Java で実装する場合はこの HTTPClient 3.x を使うことが多かったし、今回のようなポスト時のエラーに遭遇したことはなかったのですが・・・ まずここで躓きました。


次に試したのはポスト方法の変更です。sendMail 関数を以下の様な形に変え、ポストデータをプレーンテキストで送信するのではなく NameValuePair 配列で送信するように変更してみました(これもまだ期待通りには動きません):
  :
public int sendMail( String to, String subject, String html ){
  int r = 0;

  try{
    PostMethod method = new PostMethod( "https://api.sendgrid.com/api/mail.send.json" );
    method.setRequestHeader( "Authorization", "Bearer SG.*****" );
    List<namevaluepair> params = new ArrayList<namevaluepair>();
    params.add( new NameValuePair( "to", to ) );
    params.add( new NameValuePair( "from", "info@from.com" ) );
    params.add( new NameValuePair( "subject", subject ) );
    params.add( new NameValuePair( "html", html ) );
    method.setRequestBody( ( NameValuePair[] )params.toArray( new NameValuePair[0] ) );
    HttpClient client = new HttpClient();
    int sc = client.executeMethod( method );
    String json = method.getResponseBodyAsString();
      :
    r = 1;
  }catch( Exception e ){
    mothod.setRequest
    e.printStackTrace();
    r = -1;
  }

  return r;
}
  :

自分としては先程のコードと同じことを記載しているつもりでした。が、このコードは一応動いて、ステータスコードは 200 (成功)を返してくれます。

しかし、実際に送られてくるメールを受け取ると、残念ながら日本語部分が全て ? という文字に化けてしまっていました。。
2016090601
 (↑ ハロー、ワールド という結果を期待していたが文字化け)


文字化けということは文字コードの指定と実際の文字コードが違っていると読み、であれば強制的に UTF-8 で指定すれば・・・ と考えたのですが、この HTTPClient 3.1 には Content-Type で指定する以外の文字コード指定方法はありません( NameValuePair 配列で指定した本文がどのような内部処理をされているのかはわかりません。。) というわけで、この方法も詰み・・・

結論として、HTTPClient のバージョンをより新しいものに上げて対処しました。現在の最新バージョンは 4.5.2 のようです(僕は 4.5.1 を使いました):
https://hc.apache.org/httpcomponents-client-ga/

このライブラリに置き換えた上で、コードも以下のように 4.5.x 仕様に書き換えました(完成版です):
  :
public int sendMail( String to, String subject, String html ){
  int r = 0;

  try{
    PostMethod method = new PostMethod( "https://api.sendgrid.com/api/mail.send.json" );
    method.setRequestHeader( "Authorization", "Bearer SG.*****" );
    List<namevaluepair> params = new ArrayList<namevaluepair>();
    params.add( new BasicNameValuePair( "to", to ) );
    params.add( new BasicNameValuePair( "from", "info@from.com" ) );
    params.add( new BasicNameValuePair( "subject", subject ) );
    params.add( new BasicNameValuePair( "html", html ) );
    method.setEntity( new UrlEncodedFormEntity( params, "UTF-8" ) );
    CloseableHttpClient client = HttpClients.createDefault();
    CloseableHttpResponse response = client.execute( method );
    int sc = response.getStatusLine().getStatusCode();
    HttpEntity entity = response.getEntity();
    String json = EntityUtils.toString( entity, StandardCharsets.UTF_8 );
      :
    r = 1;
  }catch( Exception e ){
    mothod.setRequest
    e.printStackTrace();
    r = -1;
  }

  return r;
}
  :

この関数は期待通りに動き、実行後にメールを受け取ると、期待通りの HTML コンテンツが文字化けなしに表示されました:
2016090602


と、偉そうに書きましたが、実の所、なぜ前の2つのコードで動かないのかわかりません。最新コードでは NameValuePair 配列を UTF-8 指定で追加していて、これは旧バージョンにはない関数なので、その差で文字化けの有無になっているのかもしれません。ただ繰り返しますが、curl では最小限の指定だけで動いていたことと同じ指定をしているのに、Java でうまく動かない理由が説明できないのでした(少なくともエラーメッセージの内容をそのまま信用して対処しようとするとハマりそうな予感・・・)。

まあとりあえずは結果オーライ、ということで。あと SendGrid のメール送信を Java から、それも SDK ではなく Web API 経由で送る例はあまり見かけることがなかったので、後からやる人の助けになれば嬉しいです。


IBM のコグニティブエンジンである Watson の各種 API を実際に使って動作を確認するためのサービス: Watson API Explorer を紹介します:
https://watson-api-explorer.mybluemix.net/


上記サイトにブラウザでアクセスすると、このような画面になり、IBM Bluemix で提供されている各種 Watson API が一覧で並んでいます。ここから実際に使ってみたい API を選択します:
2016062901


今回は自然言語テキストをカテゴリ分類する NLC(Natural Language Classifier) の API を試してみましょう。というわけで Natural Language Classifier (以下 NLC)を選択します:
2016062902


すると NLC の Open API Document 形式のページが表示されます。ここに NLC で提供されている各種 API の概要と、そのエンドポイント URL、メソッド、などが一覧表示されています:
2016062903


試しに一番上の GET /v1/classfiers をクリックしてみると、この API の詳細情報が展開されます。実行時に必要なクエリーパラメータがある場合はその情報も表示され、実行結果のフォーマットサンプルも表示されています。またこの Watson API Explorer サービスの最大の特徴ですが、 "Try it out!" と書かれたボタンをクリックすると、この API を(ここで指定したパラメータで)実際に実行して、その実行結果を確認することも可能です:
2016062904


ただし、実行にはこのサービスを利用するための認証情報が必要になります。画面右上に username と password を入力するフィールドがあり、ここに Watson API を実行するための正しい認証情報を指定する必要があります:
2016062905


この認証情報は IBM Bluemix で対象サービス(今回の例であれば NLC)を追加すると確認することができます。もしまだ NLC を Bluemix で使っていない場合は追加しましょう。Bluemix のカタログページから Watson カテゴリ内の NLC を選択します:
2016062906


今回はランタイムアプリケーションとのバインドを行わずに実行するので、アプリケーション欄は「アンバインド」を選択します。そしてサービスを作成します:
2016062907


サービスが作成できたら左メニューから「サービス資格情報」を選び、この NLC サービスを利用するための認証情報を確認します。画面右にサービス資格情報の JSON テキストが表示され、その中に username と password が表示されるはずです:
2016062908


この username と password を Watson API Explorer 画面の NLC の username および password として指定します。これで準備は整いました:
2016062901


では改めて GET /v1/classifiers を実行してみましょう。この API は現在作成した(学習させた) classifier の一覧を取得する API です。特にパラメータは必要ないのでそのまま "Try it out!" ボタンをクリックします:
2016062902


すると指定した username と password を使って API が実行されます。まだ特に NLC で学習データを作っていない場合は classifier は1つも存在していないので、Response Body の "classifiers" には空配列([])が指定されているはずです。また画面内にはこの処理を curl コマンドで実行する場合に指定するコマンドや、実際にアクセスする先のエンドポイント URL(のホスト名を含めた全文字列)、HTTP レスポンスのヘッダやステータスコードなどが確認できます:
2016062903


とりあえず今回はここまで。Watson API Explorer を使うには目的の Watson サービスインスタンスを作って接続情報(username と password)を取得し、それらの情報を使って API を実行する、という流れを紹介しました。 別の機会により具体的な Watson API ごとの利用方法を紹介するつもりです。



 

このページのトップヘ