猫でもわかるWebプログラミングと副業

本業エンジニアリングマネージャー。副業Webエンジニア。Web開発のヒントや、副業、日常生活のことを書きます。

【PHP】Laravel で JSON 形式の Web API を実装する時に考えること

f:id:yoshiki_utakata:20210526114644p:plain

はじめに

この記事は、 Laravel アドベントカレンダー 4日目の記事です。

他にも素晴らしい記事が書かれているので、是非読んでみてください。

qiita.com

さて、最近、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('想定外のエラー');
});

f:id:yoshiki_utakata:20211204121437p:plain

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-&gt;{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 アドベントカレンダーで記事書いてたので、気になる人は読んでみてください。

www.utakata.work

昨日書いた PHP アドベントカレンダーの記事もあります。

www.utakata.work

Laravel アドベントカレンダー 2021には、他にもためになる記事がいろいろ書いてあります。少し難しい記事もありますが、Laravel 好きなら面白いと思います。

qiita.com

最近おすすめの本はこれです。LaravelやPHPに限らず、あらゆる開発に応用できる「ドメイン駆動設計」の本です。ドメイン駆動設計の本は難しいものばかりでしたが、この本は具体的な実装から説明が始まるため、エンジニアにとってわかりやすい本となっています。

無料のWeb 版もあるので、まずはそちらから読んでみてください。

https://nrslib.com/bottomup-ddd/

本はこちら

*1:今回は Laravel Sail (Laravel の Docker 環境をサクッと作れるやつ)を使って、PCローカルにサーバーを立ち上げています

*2:ただし、APIを叩くクライアントが正しくパースできるかは、別途確認が必要です

*3:デバッグモードがオフでも、レスポンスは HTML です。

*4:middlewareで環境変数を参照して、devならHTMLを許容してもいいかも