Laravel のリクエストだけでなく、レスポンスもバリデートしよう

こんにちは。やまゆです。

この記事は [PHP アドベントカレンダー 2022](https://qiita.com/advent-calendar/2022/php) 18 日目のものとなります。

前日の 17 日目の記事は @tanakahisateru さんの[ちょうぜつ設計とは](https://qiita.com/tanakahisateru/items/3024bdd650b6614b2f27)です。
翌日の 19 日目の記事は @ippey_s さんの [GitHub CodespacesでリモートPHP開発環境を整えてPhpStormで使う3ステップ](https://qiita.com/ippey_s/items/616213e1b2c98a68bbb7)です。

## リクエストのバリデーション

https://laravel.com/docs/9.x/validation#form-request-validation

Laravel には [FormRequest](https://laravel.com/api/9.x/search.html?search=Illuminate%5CFoundation%5CHttp%5CFormRequest) というクラスが存在します。

このクラスを利用することで、ユーザーからリクエストされた内容が正しい形式で送られてきているかをメイン処理の前にバリデートすることが可能です。

```php:app/Http/Controllers/ArticleRequest.php
public function rules()
{
    return [
        'title' => ['required', 'unique:posts', 'max:255'],
        'body' => ['required'],
    ];
}
```

このようにルールを配列で設定することにより、 Controller に入る前に自動でバリデートを行うことが出来ます。

```php:app/Http/Controllers/ArticleController.php
public function __invoke(ArticleRequest $request)
{
    /** @var array{title: string, body: string} $input */
    $input = $request->validated();

    // 信頼された値だけをここで利用可能
    $article = Article::create($input);
    $article->save();

    return ['status' => 'created'];
}
```

バリデーションエラーになった場合は `HTTP Status Code 422 Unprocessable Entity` が返却されます。

```json:errors-response.json
{
    "message": "The title must be a string.",
    "errors": {
        "title": [
            "The title must be a string.",
            "The title must be at least 1 characters."
        ],
    }
}
```

### `$request->validated()` の戻り値に型を付ける

現状だとこのように型を表記しています。

```php
    /** @var array{title: string, body: string} $input */
    $input = $request->validated();
```

しかし、与えられる型は Request 側で定義したいですね。
その場合は、 `@template` アノテーションと `@extends` アノテーションを用いることで、静的解析ツールで判断してくれるようになります。

- psalm の場合: https://psalm.dev/r/8e26718f38
- phpstan の場合: https://phpstan.org/r/d9054a8f-59a0-405e-ba86-0ecac06ad0df

```php:app/Http/Controllers/Request.php
<?php

/**
 * @copyright 2022 Masaru Yamagishi
 * @license Apache-2.0
 */

declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Foundation\Http\FormRequest;

/**
 * @link https://laravel.com/docs/9.x/validation#form-request-validation
 * @template T of array
 */
abstract class Request extends FormRequest
{
    /**
     * {@inheritDoc}
     * @return T
     */
    public function validated($key = null, $default = null)
    {
        return parent::validated($key, $default);
    }
}
```

```php:app/Packages/Account/Http/RegisterAccountRequest.php
<?php

/**
 * @copyright 2022 Masaru Yamagishi
 * @license Apache-2.0
 */

declare(strict_types=1);

namespace App\Packages\Account\Http;

use App\Http\Controllers\Request;

/**
 * @extends Request<array{
 *   email: string,
 *   password: string
 * }>
 */
final class RegisterAccountRequest extends Request
{
    /**
     * {@inheritDoc}
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'email'],
            'password' => ['required', 'password'],
            'password_confirmation' => ['required', 'confirmed'],
        ];
    }
}
```

このように定義することで、静的解析ツールで `$request->validated()` の型を解決してくれるようになります。

## レスポンスのバリデーション

Laravel は様々な手段でレスポンスを返すことが可能です。

```php:app/Http/Controllers/Api/HomeController.php
public function __invoke()
{
    // 配列でもok
    return ['hello' => 'world'];

    // Responsable なオブジェクトでもok
    // @link https://laravel.com/api/9.x/Illuminate/Contracts/Support/Responsable.html
    return response()->json(['hello' => 'world']);
}
```

しかし、エンジニアごとにレスポンスの生成方法が異なると、一貫性に問題がありますね。なので、 Response もクラスとして用意して、常にそれを利用するようにした方が保守性が上がります。

ついでに `FormRequest` 型と同じように、レスポンスの実装が間違っていないかバリデート出来るようにすればさらに安全です。

レスポンスの型が間違っている場合、クライアント側でエラーが発生することになりますが、サーバとクライアントが別のチームで開発している場合、「(C)レスポンスの型が違います」「(S)確認します」「(S)修正します」とかなりテンポが悪くなってしまいます。なのでサーバ側の実装時に出来るだけ感知できるようになっているべきです。

```php:app/Http/Controllers/Response.php
<?php

/**
 * @copyright 2022 Masaru Yamagishi
 * @license Apache-2.0
 */

declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Validator;
use JsonSerializable;

/**
 * @link https://laravel.com/docs/9.x/validation#manually-creating-validators
 */
abstract class Response implements JsonSerializable, Responsable
{
    /**
     * Constructor
     *
     * @param array|null $result 後でバリデートするので、入力は array と広くとる
     */
    public function __construct(private readonly ?array $result = null)
    {
    }

    /**
     * Gets validation rules
     * ここで FormRequest と同じ形式でバリデーションルールを決める
     *
     * @return array
     */
    abstract public function rules(): array;

    /**
     * {@inheritDoc}
     */
    public function jsonSerialize(): mixed
    {
        if (is_null($this->result)) {
            // 特に返すものがない場合は、空でも良いですが中身があった方が嬉しい場合があります
            // ※ `[]` こう返ってしまうとクライアントのパースに問題が出る可能性があるなど
            return ['ok' => true];
        }
        return $this->result;
    }

    /**
     * {@inheritDoc}
     */
    public function toResponse($request)
    {
        $payload = $this->jsonSerialize();
        $rules = $this->rules();

        // バリデータを生成
        $validator = Validator::make($payload, $rules);

        // ※ `$validator->validate()` メソッドを使うと失敗時に 422 を返してしまうので
        // 代わりに `fails()` を使います
        if ($validator->fails()) {
            // バリデーションに失敗した場合は \LogicException の派生クラスを返却する
            // ※レスポンスが異常＝サーバ側の実装にミスがあるということなので
            throw new ResponseValidationException($validator->errors());
        }

        return new JsonResponse($payload);
    }
}

```

```php:app/Http/Controllers/Api/HomeResponse.php
<?php

/**
 * @copyright 2022 Masaru Yamagishi
 * @license Apache-2.0
 */

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Response;

final class HomeResponse extends Response
{
    /**
     * {@inheritDoc}
     */
    public function rules(): array
    {
        // FormRequest::rules と書き方は同じ
        return [
            'hello' => ['required', 'string'],
        ];
    }
}
```

これで無事 Response もバリデーションすることが可能です。


## HTTP 関連クラスの自動生成

OpenAPI などの API 定義規格で JSON HTTP リクエスト・レスポンスの型を定義しておけば、そこから Controller/Request/Response クラスを自動生成することが可能です。

```yml:openapi.yaml
openapi: 3.0.3
info:
  title: Laravel Schema sample
  version: 1.0.0
paths:
  /api:
    get:
      operationId: Index
      responses:
        '200':
          description: OkResponse
          content:
            application/json:
              schema:
                type: object
                properties:
                  hello:
                    type: string
                    example: world
                    # x- prefix は自由に記述できる
                    x-laravel-validation:
                      - required
                      - string
                required:
                  - hello
  /api/account/register:
    post:
      operationId: RegisterAccount
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  format: email
                  x-laravel-validation:
                    - required
                    - email
                password:
                  type: string
                  x-laravel-validation:
                    - required
                    - min:8
                password_confirmation:
                  type: string
                  x-laravel-validation:
                    - required
                    - confirmed
              required:
                - email
                - password
                - password_confirmation
      responses:
        '200':
          description: OkResponse
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
                    x-laravel-validation:
                      - required
                      - boolean
                required:
                  - ok
```

こういった定義を行って、 schema などをパースして自動生成させることが可能です。