Laravelのjson応答でユニコードのエスケープをやめさせる

# はじめに

発端は、Laravelでjson apiサーバを作っていて、jsonの中の日本語文字列がエスケープされていると読めなくてつらいなと思ったところから始まります。ちゃんと動くようになってしまえばクライアントがデコードしてしまうからエスケープされていても問題はないわけですが、それまでのデバッグ過程で通信内容をのぞいたりログをとったり手でアクセスしてみたりという機会はいくらでもありますからね。

ということで、JSON_UNESCAPED_UNICODEオプションを使いたいわけですがどうすれば指定できるか調べていきます。


# どこでjson_encodeしているか

## `\Illuminate\Http\JsonResponse`クラス

まずjson応答といえば思いつくのがこのクラスです。

エンコード処理は`setData`関数の中にあります。

```php
   public function setData($data = [])
    {
        $this->original = $data;

        if ($data instanceof Jsonable) {
            $this->data = $data->toJson($this->encodingOptions);
        } elseif ($data instanceof JsonSerializable) {
            $this->data = json_encode($data->jsonSerialize(), $this->encodingOptions);
        } elseif ($data instanceof Arrayable) {
            $this->data = json_encode($data->toArray(), $this->encodingOptions);
        } else {
            $this->data = json_encode($data, $this->encodingOptions);
        }

        if (! $this->hasValidJson(json_last_error())) {
            throw new InvalidArgumentException(json_last_error_msg());
        }

        return $this->update();
    }
```

`$this->encodingOptions`に保存したオプションを渡すようになっていて、コンストラクタのパラメタや`setEncodingOptions`メソッドで設定できます。

## `\Illuminate\Http\Response`クラス

`Response`クラスもそういえば`Response::create()`で配列やモデルを渡すとjsonにしてくれますが、この処理は自前でもっていました。それがこの`morphToJson`メソッドです。

```php
    protected function morphToJson($content)
    {
        if ($content instanceof Jsonable) {
            return $content->toJson();
        } elseif ($content instanceof Arrayable) {
            return json_encode($content->toArray());
        }

        return json_encode($content);
    }
```

オプションの影すらありません。潔すぎます。とりあえずResponseに配列やモデルを渡すパターンは使わない方が良さそう...

# JsonResponseをnewするところ

`\Illuminate\Http\Response`のことはとりあえず使わなければいいので無視することにして、`\Illuminate\Http\JsonResponse`が作られるときにコンストラクタの$optionsパラメタにJSON_UNESCAPED_UNICODEを設定する余地がないか調べていきます。

## `\Illuminate\Routing\ResponseFactory::json`

`Response`ファサードで`Response::json(...)`したときや`response`ヘルパーで`response()->json(...)したときに呼ばれる奴です。

```php
    public function json($data = [], $status = 200, array $headers = [], $options = 0)
    {
        return new JsonResponse($data, $status, $headers, $options);
    }
```

## `\Illuminate\Routing\Router::toResponse`

コントローラの戻り値としてResponseクラスではなくいきなり配列とかモデルを返したときにいい感じにjson応答にする処理です。

改めて読んでみてはじめて知りましたが、EloquentモデルでwasRecentlyCreatedのときは201応答にするとか小技が効いてますね。

```php
    public static function toResponse($request, $response)
    {
        if ($response instanceof Responsable) {
            $response = $response->toResponse($request);
        }

        if ($response instanceof PsrResponseInterface) {
            $response = (new HttpFoundationFactory)->createResponse($response);
        } elseif ($response instanceof Model && $response->wasRecentlyCreated) {
            $response = new JsonResponse($response, 201);
        } elseif (! $response instanceof SymfonyResponse &&
                   ($response instanceof Arrayable ||
                    $response instanceof Jsonable ||
                    $response instanceof ArrayObject ||
                    $response instanceof JsonSerializable ||
                    is_array($response))) {
            $response = new JsonResponse($response);
        } elseif (! $response instanceof SymfonyResponse) {
            $response = new Response($response);
        }

        if ($response->getStatusCode() === Response::HTTP_NOT_MODIFIED) {
            $response->setNotModified();
        }

        return $response->prepare($request);
    }
```

## `\Illuminate\Foundation\Exceptions\Handler::prepareJsonResponse`

例外ハンドラで応答がjsonのときに使われる奴です。

```php
   protected function prepareJsonResponse($request, Exception $e)
    {
        return new JsonResponse(
            $this->convertExceptionToArray($e),
            $this->isHttpException($e) ? $e->getStatusCode() : 500,
            $this->isHttpException($e) ? $e->getHeaders() : [],
            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
        );
    }
```

# どう直すか

`\Illuminate\Http\JsonResponse`や`\Illuminate\Http\Response`を自分で直接呼んでいるところはどっちにしろ直さなければならないのであきらめます。コントローラで`return new JsonResponse::create(...)`などとしているところはとりあえずいい機会なので修正することにします。

また、配列やモデルをコントローラから返して自動的にjson応答に変換させる機能は使わないことにします。

そうすると修正する必要があるのは、

* `\Illuminate\Routing\ResponseFactory`のjsonメソッド
* `\Illuminate\Foundation\Exceptions\Handler`のprepareJsonResponseメソッド

の2つになります。

ResponseFactoryはDIコンテナで解決されるので、修正したクラスに入れ替えてやります。

例外ハンドラの方はもともとextendした`App\Exceptions\Handler`を使うようになっているので、そちらで上書きしてやります。

`App\Routing\MyResponseFactory`:

```php
<?php

namespace App\Routing;


use Illuminate\Routing\ResponseFactory;

class MyResponseFactory extends ResponseFactory
{
    public function json($data = [], $status = 200, array $headers = [], $options = JSON_UNESCAPED_UNICODE)
    {
        return parent::json($data, $status, $headers, $options);
    }
}
```

`bootstrap/app.php`に追加

```php
$app->singleton(
    ResponseFactoryContract::class,
    function ($app) {
        return new MyResponseFactory($app[ViewFactoryContract::class], $app['redirect']);
    }
);
```

`App\Execptions\Handler`に追加

```php
    protected function prepareJsonResponse($request, Exception $e)
    {
        return new JsonResponse(
            $this->convertExceptionToArray($e),
            $this->isHttpException($e) ? $e->getStatusCode() : 500,
            $this->isHttpException($e) ? $e->getHeaders() : [],
            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
        );
    }
}
```

# 実行例

```console
% curl -H 'accept: application/json' http://127.0.0.1:8000/api/foo\?p=%E3%81%82
{"foo":"日本語","p":"あ"}
```

```console
% curl -H 'accept: application/json' http://127.0.0.1:8000/api/foo\?p=%E3%81%82%E3%81%84%E3%81%86
{
    "message": "invalid p: あいう",
    "exception": "RuntimeException",
(以下略)
```

# おわりに

コントローラの戻り値をリターンするところで

```php
return Response::json(...); // ファサードを使うパターン
```

または

```php
return response()->json(...); // ヘルパーを使うパターン
```

という書き方をした場合という限定つきですが、普通のLaravelの書き方でJSON_UNESCAPED_UNICODEオプションを使うことができたのでよしとします。(というかLaravelいろんな書き方できすぎ)

でもできればLaravelの設定を一ついじるだけでいいようにしてほしいですね。

本件についてはPHPユーザーズ(日本語)slack(phpusers-ja.slack.com)のLaravelチャンネルの皆さんに相談に乗っていただきました。ありがとうございました。

## lumenについての追記

lumenの`response`ヘルパーは`Laravel\Lumen\Http\ResponseFactory`がハードコードされているのでDIコンテナの登録を入れ替えるやり方では手が出せません。`Response`ファサードはそもそもありません。(正確にはファサードのクラス自体は残ってますがalias定義もないし、`Illuminate\Contracts\Routing\ResponseFactory`がコンテナに登録されてないので使えません)

多分responseヘルパーを置き換えるとかそんな感じになるかと。

```php
if (! function_exists('response')) {
    /**
     * Return a new response from the application.
     *
     * @param  string  $content
     * @param  int     $status
     * @param  array   $headers
     * @return \Illuminate\Http\Response|\Laravel\Lumen\Http\ResponseFactory
     */
    function response($content = '', $status = 200, array $headers = [])
    {
        $factory = new Laravel\Lumen\Http\ResponseFactory;

        if (func_num_args() === 0) {
            return $factory;
        }

        return $factory->make($content, $status, $headers);
    }
}
```

# 追記(ミドルウェアで行う方法)

ミドルウェアで行う方法を教えていただきました。

[開発ブログ: JSONレスポンスのUnicodeエスケープ無効化はミドルウェアで！](https://nextat.co.jp/staff/archives/203)

すっきりしていて良いです。感動。