PCがあれば何でもできる!

へっぽこアラサープログラマーが、覚えたての知識を得意げにお届けします

【CakePHP 2.x】REST APIの構築+マルチバイト文字のエスケープ防止(uXXXXの形式になるのを防止)

CakePHP 2.xでREST APIを作る必要があったのでメモ。

以下の3点について書いてます。

1. レスポンスをJSONで返す
2. マルチバイト文字のエスケープ防止
3. PHPUnitでテストする際の注意点

SecurityComponentとの絡みはまた次回。

特にPHPUnitで、JSONの結果が取れずにハマりましたorz
APIの検証に限って言えばPHPUnitを使う必要はないですが、あれこれ使うのは嫌という方の参考になれば。

環境

  • PHP 5.3.29
  • CakePHP 2.6.3

1. レスポンスをJSONで返す

コンポーネントを追加

レスポンスをJSONで返す際は、RequestHandlerComponentを使うとシンプルにできます。
まずは、使用するコントローラにコンポーネントを追加しましょう。

/* app/Controller/ApiController.php */

<?php
App::uses('AppController', 'Controller');

class ApiController extends AppController {

  // コンポーネントを追加
  public $components = array('RequestHandler');
}

余談ですが、App::uses('AppController', 'Controller');の記述がないと、PHPUnitが上手く動きません^^;

ルーティングを追加
/* app/Config/routes.php */

<?php
/* 省略 */

// 拡張子.jsonの呼び出しを許可
// 呼び出した場合、RequestHandlerComponentが自動でJSONにフォーマットしてくれる
Router::parseExtensions('json');

// 対象のルートを追加
// `ext`を指定することで、拡張子を強制的に付けてくれる
Router::connect('/api/:action', array('controller' => 'api', 'ext' => 'json'));
アクションを追加
/* app/Controller/ApiController.php */

<?php
App::uses('AppController', 'Controller');

class ApiController extends AppController {

 /* 省略 */

  // アクションを追加
  // setで出力値を設定後、_serializeにキー名を指定することで、自動でJSONにフォーマットされる
  public function sample() {
    $this->set('result', true);
    $this->set('message', 'This is sample.');
    $this->set('_serialize', array('result', 'message'));
  }
}
出力結果
/* http://[ドメイン]/api/sample.json */

{
  "result": 1,
  "message":  "This is sample."
}

2. マルチバイト文字のエスケープ防止

このままでは、日本語のようなマルチバイト文字は自動でuXXXXの形式にエスケープされてしまいます。
クライアント側でデコードすれば気にする必要はないのですが、サーバ側でデバッグしようと思うと、地味に面倒です。

例えばこんな感じにエスケープされます。
テスト => \u30C6\u30B9\u30C8

原因と対策

uXXXXの形式にエスケープされてしまうのは、PHP標準のjson_encode関数が原因です。
このjson_encode関数を通すと、自動でuXXXXの形式にエスケープされてしまいます。
RequestHandlerComponentもこの関数を使用して、JSONを生成しているために現象が発生していますので、その辺りのコードをうまく書き換える必要があります。

エスケープ防止用の関数を追加

PHP 5.4以上の場合は、json_encode関数にJSON_UNESCAPED_UNICODEのオプションを付けることで、エスケープさせないようにできます。

<?php
/* 例 */
var_dump(json_encode(array('test' => 'てすと'), JSON_UNESCAPED_UNICODE));
// string(20) "{"test":"てすと"}"

ただしPHP 5.3以下の場合は、このオプションが存在しないため、自作の必要があります。

――すみません、PHP 5.4以上でも上記のメソッド使うより、uXXXXの形式にエスケープされた文字をデコードする方が、カスタマイズが少なくて済みそうです。以降、後者の手順の説明となります。

今回は、こちらの記事のソースコードを使わせていただきたいと思います。
PHPでUnicodeアンエスケープしたJSONを出力する関数 - オープンソースこねこね

php自体の拡張ということで、Config辺りにフォルダを切って作成してみました。
もし正しい作法があれば教えてください^^;

/* app/Config/Extension/encode.php */

<?php
/**
 * Unicodeエスケープされた文字列をUTF-8文字列に戻す。
 * 参考:http://d.hatena.ne.jp/iizukaw/20090422
 * @param unknown_type $str
 */
function unicode_encode($str) {
  return preg_replace_callback("/\\\\u([0-9a-zA-Z]{4})/", "encode_callback", $str);
}

function encode_callback($matches) {
  return mb_convert_encoding(pack("H*", $matches[1]), "UTF-8", "UTF-16");
}

さらに上記のコードをbootstrap.php辺りで読み込みます。
こちらもとりあえず自分好みに書いてますが、正しい作法があればそちらに従ってください。

/* app/Config/bootstrap.php */

<?php
/* 省略 */

/**
 * PHP拡張読み込み
 * Extensionディレクトリ内のファイルを全てrequireする
 */
$extension_path = APP.DS.'Config'.DS.'Extension';
if ($handle = opendir($extension_path)) {
  while (false !== ($file = readdir($handle))) {
    $filename = $extension_path.$file;
    if (file_exists($filename) && !is_dir($filename)) {
      require_once($filename);
    }
  }
  closedir($handle);
}

これで、エスケープ防止用の関数ができました。

JsonViewを拡張する

RequestHandlerComponentでは、JsonViewというViewを使い、_serializeのデータをJSONにフォーマットしています。

今回はこのJsonViewを継承し、フォーマット部分を書き換えたいと思います。

ViewディレクトリにJsonView拡張用のJsonMultiByteView.phpを生成します。

/* app/View/JsonMultiByteView.php */

<?php
App::uses('JsonView', 'View');

// JsonViewを継承
class JsonMultiByteView extends JsonView {

  // オーバーライド
  protected function _serialize($serialize) {

    //既存処理(エスケープされる)
    $json = parent::_serialize($serialize);

    // エスケープをデコード
    return unicode_encode($json);
  }
}
拡張したJsonMultiByteViewとJsonViewを差し替える

beforeRenderで差し替えました。

/* app/Controller/ApiController.php */

<?php
App::uses('AppController', 'Controller');

class ApiController extends AppController {

  /* 省略 */

  // JsonViewとJsonMultiByteViewを差し替え
  public function beforeRender() {
    if ($this->viewClass === 'Json') {
      $this->viewClass = 'JsonMultiByte';
    }
  }
}
日本語が表示されるか確認
/* app/Controller/ApiController.php */

<?php
App::uses('AppController', 'Controller');

class ApiController extends AppController {

 /* 省略 */

  // アクションの出力を日本語に変更
  public function sample() {
    $this->set('result', true);
    $this->set('message', 'これはサンプルです'); // 英語→日本語に変更
    $this->set('_serialize', array('result', 'message'));
  }
}
/* http://[ドメイン]/api/sample.json */

{
  "result": 1,
  "message":  "これはサンプルです"
}

問題なし!
もしデバッグ時のみに限定したい場合は、Configure::read('debug')の値を確認して分岐すると良いかもしれません。

3. PHPUnitでテストする際の注意点

コントローラのテストのみサンプルを記載します。
通常通り、testActionメソッドが使用できますが、returnオプションを付けるという点だけ注意が必要です。

/* app/Test/Case/Controller/ApiControllerTest.php */

<?php
App::uses('Controller', 'Controller');

class ApiControllerTest extends ControllerTestCase {

  // GETの場合
  public function testSampleGet() {
    $json = $this->testAction('/api/sample', array(
      'method' => 'GET',
      'return' => 'contents'  // ※必須
    ));

    $result = json_decord($json, true);
    $this->assertTrue($result['result']);
  }

  // POSTの場合
  public function testSamplePost() {
     $data = array(
      'Sample' => array(
        'id' => 1,
        'password' => 'password',
      )
    );

    $json = $this->testAction('/api/sample', array(
      'data' => $data,
      'method' => 'POST',
      'return' => 'contents'  // ※必須
    ));

    $result = json_decord($json, true);
    $this->assertTrue($result['result']);
  }
}

returnオプションはcontents以外のものを指定し、$this->contentsでJSONを取得する形でも大丈夫です。

しかし、returnオプションが未指定だと、$this->contentsがnullとなるのでご注意ください。