- はじめに
- JSON 形式の Web API とは
- Laravel で JSON を返す基本的な実装
- JsonSerializable インタフェースを使う
- 想定外の例外が発生した場合の対応
- Laravel デフォルトのエラーハンドリングの実装
- エラーハンドラの実装を変更したい場合
- まとめ
はじめに
この記事は、 Laravel アドベントカレンダー 4日目の記事です。
他にも素晴らしい記事が書かれているので、是非読んでみてください。
さて、最近、Laravel で JSON の Web API を実装したのですが、いろいろ学びがあったので、まとめます。
これから実装する人の参考になれば幸いです。
JSON 形式の Web API とは
この記事では、レスポンスがJSON形式の Web API を指します。
JSON形式の Web API といえば REST API をイメージする人がいるかもしれませんが、この記事では、 REST API に限らない話をします。
Laravel で JSON を返す基本的な実装
超シンプルに、 routes/api.php
に書くとこうなります。
<?php use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use Illuminate\Http\Response; Route::get('/ping', function (Request $request) { return response()->json( ['result' => 'pong'], Response::HTTP_OK ); });
ポイント
response()->json(...)
で JSON 形式のレスポンスを返せる- 第一引数は JSON 化可能なオブジェクト
- 第二引数はステータスコード
Illuminate\Http\Response
にステータスコードの定数が用意されている
curl で叩いてみます。 *1
$ curl http://127.0.0.1/api/ping -v * Trying 127.0.0.1... * TCP_NODELAY set * Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) > GET /api/ping HTTP/1.1 > Host: 127.0.0.1 > User-Agent: curl/7.64.1 > Accept: */* > < HTTP/1.1 200 OK < Host: 127.0.0.1 < Date: Sat, 04 Dec 2021 02:39:49 GMT < Connection: close < X-Powered-By: PHP/8.1.0 < Cache-Control: no-cache, private < Date: Sat, 04 Dec 2021 02:39:49 GMT < Content-Type: application/json < X-RateLimit-Limit: 60 < X-RateLimit-Remaining: 59 < Access-Control-Allow-Origin: * < * Closing connection 0 {"result":"pong"}
ポイント
- レスポンスが 200 OK になっている
- Content-Type が application/json になっている
ここまではすでに理解している人も多いかと思います。
JsonSerializable インタフェースを使う
これは、Laravel や Web API に限らず、PHP で JSON を扱う時のテクニックですが、 PHP には JsonSerializable
という interface があります。
JsonSerializable を implement したクラスには、 jsonSerialize メソッドを実装する必要があります。 jsonSerialize メソッドで配列を返すと、その通りに JSON に変換されます。
get_object_vars($this);
を返すことで、オブジェクトのプロパティを配列に変換して返すことができますので、これらを組み合わせると、以下のように書けます(コードが長くなってきたので、 Controller を実装することにします)。
<?php use Illuminate\Http\Response; use Illuminate\Routing\Controller as BaseController; /** Controller **/ class Controller extends BaseController { public function __invoke() { $student = new Student( new Name('Yoshiki', 'Utakata'), 17 ); return response()->json( $student, Response::HTTP_OK ); } } /** JSON化可能なオブジェクト **/ class Name implements \JsonSerializable { // PHP 8.0 で追加された コンストラクタのプロモーション と // PHP 8.1 で追加された readonly を使っている public function __construct( public readonly string $first, public readonly string $last ) { } public function jsonSerialize(): array { return get_object_vars($this); } } class Student implements \JsonSerializable { public function __construct( // ↑の Name クラスを使っている public readonly Name $name, public readonly int $age ) { } public function jsonSerialize(): array { return get_object_vars($this); } }
Controller に __invoke
メソッドを実装すると、以下のようにシンプルにルーティングを記述できるようになります。
<?php Route::get('/students', \App\Http\Controllers\Controller::class);
curl で叩くとこうなります。クラスの構造通りに JSON のレスポンスが生成されています。
$ curl -v http://127.0.0.1/api/students * Trying 127.0.0.1... * TCP_NODELAY set * Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) > GET /api/students HTTP/1.1 > Host: 127.0.0.1 > User-Agent: curl/7.64.1 > Accept: */* > < HTTP/1.1 200 OK < Host: 127.0.0.1 < Date: Sat, 04 Dec 2021 02:49:32 GMT < Connection: close < X-Powered-By: PHP/8.1.0 < Cache-Control: no-cache, private < Date: Sat, 04 Dec 2021 02:49:32 GMT < Content-Type: application/json < X-RateLimit-Limit: 60 < X-RateLimit-Remaining: 58 < Access-Control-Allow-Origin: * < * Closing connection 0 {"name":{"first":"Yoshiki","last":"Utakata"},"age":17}
JsonSerializable を使うと便利な点
エラーレスポンスを、あらゆる API で共通のフォーマットにしたい場合、あらかじめ ErrorResponse クラスを定義しておくことで、レスポンスフォーマットがブレるのを防げます。
<?php // PHP 8.1 で追加された enum も JsonSerializable を implements できる enum ErrorCode implements \JsonSerializable { case InvalidArgument; case StudentNotFound; case InternalError; public function jsonSerialize(): string { return match($this) { ErrorCode::InvalidArgument => 'INVALID_ARGUMENT', ErrorCode::StudentNotFound => 'STUDENT_NOT_FOUND', ErrorCode::InternalError => 'INTERNAL_ERROR' }; } } /** エラーレスポンスを返すときは必ずこのクラスを使うようにする **/ class ErrorResponse implements \JsonSerializable { public function __construct( public readonly ErrorCode $errorCode, public readonly string $detail ) { } public function jsonSerialize(): array { return get_object_vars($this); } }
Controller はこうなります。
<?php class Controller extends BaseController { public function __invoke() { return response()->json( new ErrorResponse(ErrorCode::InvalidArgument, 'xxxパラメータが間違っています'), Response::HTTP_BAD_REQUEST, // PHP 8.0 から追加された 名前付き引数 を利用できる options: JSON_UNESCAPED_UNICODE ); } }
オプションに JSON_UNESCAPED_UNICODE
を指定すると、Unicodeがエスケープされなくなるため、動作確認の際に便利です。 *2
# エスケープしていない状態 $ curl http://127.0.0.1/api/students {"errorCode":"INVALID_ARGUMENT","detail":"xxxパラメータが間違っています"} # エスケープしている状態 $ curl http://127.0.0.1/api/students {"errorCode":"INVALID_ARGUMENT","detail":"xxx\u30d1\u30e9\u30e1\u30fc\u30bf\u304c\u9593\u9055\u3063\u3066\u3044\u307e\u3059"}
json メソッドの第3引数は headers 、第4引数が options ですが、PHP 8.0 に実装された名前付き引数を使い、 options: JSON_UNESCAPED_UNICODE
のように書くことが可能になりました。
想定外の例外が発生した場合の対応
ここから更に Laravel の深みに入っていきます。
Laravel デフォルトのエラー画面
どこからもキャッチされていない例外が送出された時、 Laravel は自動的にそれをキャッチし、エラー画面を返します。
<?php Route::get('/error', function (Request $request) { throw new RuntimeException('想定外のエラー'); });
JSON API を提供しているにも関わらず、 HTML が返ってきた場合、クライアントは正しくハンドリングできないことがあります。そこで、このエラーレスポンスも JSON 化してあげます。
Laravel のレスポンス形式は Accept ヘッダで決まる
何も指定しないと、Laravel のレスポンスは HTML で返ってきます。 *3
# レスポンスが長すぎるのでファイルに一回書き出す $ curl http://127.0.0.1/api/error > error.html $ head error.html <!doctype html> <html class="theme-light"> <!-- RuntimeException: 想定外のエラー in file /var/www/html/routes/api.php on line 19 #0 /var/www/html/vendor/laravel/framework/src/Illuminate/Routing/Route.php(238): Illuminate\Routing\RouteFileRegistrar->{closure}() (以下、スタックトレース)
ヘッダーに Accept: application/json
を付けると、JSONで返ってきます。
$ curl http://127.0.0.1/api/error -H "Accept: application/json" { "message": "\u60f3\u5b9a\u5916\u306e\u30a8\u30e9\u30fc", "exception": "RuntimeException", "file": "/var/www/html/routes/api.php", "line": 19, "trace": [ { "file": "/var/www/html/vendor/laravel/framework/src/Illuminate/Routing/Route.php", "line": 238, "function": "{closure}", "class": "Illuminate\\Routing\\RouteFileRegistrar", "type": "->" }, (以下略)
クライアントから Laravel の Web API を叩く場合は、Accept: application/json
を必ず付けるようにしましょう(そもそも、クライアントが JSON を想定しているのであれば、そうすべきです)。
Laravel のコードを修正し、レスポンスの形式を JSON に固定することもできますが、動作確認している時には、エラー内容が HTML で見られる方が便利です。
JSONしか返さないようにしたい!と思うなら、Laravel の middleware のを使い、リクエストヘッダの Accept を確認して、application/json 以外の場合はエラーにするのが良さそうです。*4
Laravel デフォルトのエラーハンドリングの実装
Laravel デフォルトのエラーハンドリングの挙動は、app/Exceptions/Handler.php
を修正すれば変更できます。
デフォルトではほとんど何も実装されてないので、親クラスである Illuminate\Foundation\Exceptions\Handler
がデフォルトの挙動になります。
どのエラーハンドラを利用するかは、bootstrap/app.php
で設定されています。
<?php $app->singleton( Illuminate\Contracts\Debug\ExceptionHandler::class, App\Exceptions\Handler::class );
ここをを修正すれば、ルートや環境に応じてハンドラを使い分ける等も可能です。
エラーハンドラの実装を変更したい場合
先程、 ErrorResponse クラスを実装しましたが、想定外のエラーが起きた場合も、レスポンスのフォーマットをこれに合わせたいとします。
<?php class ErrorResponse implements \JsonSerializable { public function __construct( public readonly ErrorCode $errorCode, public readonly string $detail ) { } public function jsonSerialize(): array { return get_object_vars($this); } }
Illuminate\Foundation\Exceptions\Handler
の実装を追っていくと、だいたい関係してくるのは以下のメソッドです。
render
shouldReturnJson
prepareJsonResponse
convertExceptionToArray
render
は、エラー画面を表示するメソッドです。
render
メソッドの中ではいろいろやってますが、最終的に、 shouldReturnJson
メソッドで、JSON レスポンスを返すべきかどうか判定し、JSON メソッドを返すべき場合には prepareJsonResponse
を呼んでいます。
prepareJsonResponse
の中で、 convertExceptionToArray
して、例外を一度配列に変換し、その配列を JSON 文字列に変換しています。
render メソッドを修正して想定した Json を返すように
基本的には、もとの render メソッドの実装を確認しながら、この render メソッドを、自分の意図したもので上書きするのが良いでしょう。
こうすることで、想定外のレスポンスを返すことはなくなります。
実装例
<?php public function render($request, Throwable $e) { if ($e instanceof NotFoundHttpException) { return new JsonResponse( new ErrorResponse( ErrorCode::ApiPathNotFound, 'API が見つかりませんでした' ), Response::HTTP_NOT_FOUND, options: JSON_UNESCAPED_UNICODE ); } return new JsonResponse( new ErrorResponse( ErrorCode::InternalError, 'サーバー内エラーが発生しました' ), Response::HTTP_INTERNAL_SERVER_ERROR, options: JSON_UNESCAPED_UNICODE ); }
本当はもっと分岐が必要だと思うので、元の render
メソッドの中身を確認しながら実装していくのが良いです。
その他の修正例
shouldReturnJson メソッドを修正して常に JSON を返すようにする
app/Exceptions/Handler.php
で、 shouldReturnJson
メソッドを上書きし、常に true を返せば、レスポンスは常に JSON で返すようになります。
環境変数によって分岐すれば、本番はJSONに固定することもできます。
prepareJsonResponse メソッドを修正して JSON のフォーマットを変更する
prepareJsonResponse
メソッドか、 convertExceptionToArray
を修正すれば、レスポンスのフォーマットを修正できますが、
- 今回は、ErrorResponse クラスを使いたいので、ToArray するわけではない
- JSON エンコードの options に
JSON_UNESCAPED_UNICODE
をつけたい
などの理由から、 prepareJsonResponse
を修正するのがおすすめです。
この方法ですが、 render
メソッドを見てもらえば分かる通り、一部 prepareJsonResponse
を通らないケースがありますので、その点は気をつけてください。
まとめ
Laravel で JSON Web API を実装した時に、私が学んだ知見をまとめました。
- Laravel のレスポンスのフォーマットは、リクエストの Accept ヘッダで決まる
- リクエストを送るときはちゃんと Accept ヘッダを付ける
app/Exceptions/Handler.php
を修正することで、想定外のエラーが起きた場合のレスポンス処理を改善できる
Laravel で Json Web API を実装している人は参考にしてみてください。
去年も Laravel アドベントカレンダーで記事書いてたので、気になる人は読んでみてください。
昨日書いた PHP アドベントカレンダーの記事もあります。
Laravel アドベントカレンダー 2021には、他にもためになる記事がいろいろ書いてあります。少し難しい記事もありますが、Laravel 好きなら面白いと思います。
最近おすすめの本はこれです。LaravelやPHPに限らず、あらゆる開発に応用できる「ドメイン駆動設計」の本です。ドメイン駆動設計の本は難しいものばかりでしたが、この本は具体的な実装から説明が始まるため、エンジニアにとってわかりやすい本となっています。
無料のWeb 版もあるので、まずはそちらから読んでみてください。
https://nrslib.com/bottomup-ddd/
本はこちら