CakePHP4 の フォームでバリデーション

## 環境

PHP 8.0.16RC1
Apache/2.4.52 (Debian)
CakePHP 4.3.6

## やること

CakePHP4 のフォーム（特に[モデルのないフォーム](https://book.cakephp.org/4/ja/core-libraries/form.html)）を使ってバリデーションを実施する。

フォームチェック（バリデーション）の必要のあるコントローラのメソッド単位で
逐一Formクラスを呼び出すのはあまりしたくないというか、なるべく一般化したい。
もしかしてちゃんとやれる方法があるのかもしれないけどわからなかったので。

## beforeFilter でフォームチェックしたいメソッドのみチェックを実施する

XxxxxController の yyyyyメソッドがあったとしたら
App/Xxxxx/YyyyyForm クラスを探して呼び出すようにする。

```php:CommonController.php
function beforeFilter(EventInterface $event)
{
	$controllerName = $this->name;
	$actionName = ucfirst($this->request->getParam('action'));
	$formClassName = "\\App\\Form\\{$controllerName}\\{$actionName}Form";

	if (class_exists($formClassName)) {
		$validForm = new $formClassName();
		if ($validForm->execute($this->getData())) { // フォームチェックの実行
			$validForm->success(); // 成功時に呼び出すメソッドを定義
		} else {
			$ret = $validForm->failed(); // 失敗時に呼び出すメソッドを定義
			if($ret === false) {
				// falseなら何もしない
			} else if (is_array($ret)) {
				// 配列ならjsonとしてreturn
				$this->autoRender = false;
				return $this->response->withType('application/json')->withStringBody(json_encode($ret));
			} else {
				// 文字列ならリダイレクト
				return $this->redirect($ret);
			}
		}
	} else {
		// Formクラスがなければ何もしない
	}
	return parent::beforeFilter($event);		
}
```

## Formの共通クラス
```/src/Form/CommonForm.php
<?php
namespace App\Form;

use Cake\Form\Form;
use Cake\Form\Schema;
use Cake\Log\LogTrait;
use Cake\Validation\Validator;
use App\Validation\AppValidator;

class CommonForm extends Form
{
	use LogTrait;

	protected $_form = [];

	/**
	 * Constructor
	 */
	public function __construct()
	{
		parent::__construct();
	}

	/**
	 * A hook method intended to be implemented by subclasses.
	 * 
	 * FormHelper が HTML フォームを作成する際に使用する スキーマデータを定義するために使います。
	 * フィールドの型、長さ、および精度を定義できます。
     *
     * @param \Cake\Form\Schema $schema The schema to customize.
     * @return \Cake\Form\Schema The schema to use.
     */
    protected function _buildSchema(Schema $schema): Schema
    {
		return parent::_buildSchema($schema);
    }

    /**
	 * The validator that can be modified to add some rules to it.
	 * 
     * バリデーターを加えることができる Cake\Validation\Validator のインスタンスを受け取ります。
     *
     * @param \Cake\Validation\Validator $validator The validator that can be modified to
     * add some rules to it.
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
		// FormクラスにおけるValidatorクラスを継承する方法がわからなかったので上書き
		$validator = new AppValidator($this->_form);
		foreach ($this->_form as $field => $rule) {
			if ($rule['required']) {
				$validator->requirePresence($field);
				$validator->notEmptyString($field);
			}
			switch ($rule['type']) {
				case 'integer':
					$validator->integer($field);
					break;
				// 必要に応じて追記する
			}
 		}
        return $validator;
    }

    /**
     * Hook method to be implemented in subclasses.
     *
     * execute() が呼ばれ、データが有効な時に望むふるまいを定義します。
     *
     * @param array $data Form data.
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        return parent::_execute($data);
    }

	/**
	 * 成功時の処理を記述する
	 */
	public function success() {
		return true;
	}
	
	/**
	 * 失敗時の処理を記述する
	 * 
	 * @return string|array|bool $url リダイレクト先URL|json|false
	 *         
	 */
	public function failed() {
		return '/'; // リダイレクトする場合は文字列
		// return ['data'=>'aaa']; //ajaxの戻り値を返す場合は配列
		// return false; // 何もしない場合はfalse
	}
	
	/**
	 * Ajaxエラーレスポンスを出力して終了
	 *
	 * @param mixed $message string エラーメッセージ | Exception
	 */
	public function respondAjaxError()
	{
		$message = [];
		foreach ($this->getErrors() as $field => $msgs) {
			foreach ($msgs as $rule => $msg) {
				$message[] = $msg;
			}
		}

		$json = array(
			'status' => false,
			'error'  => implode(PHP_EOL, $message),
		);
		return $json;
	}
}
```

メッセージも適宜オリジナルで定義したいのでValidatorクラスを継承
（resources/locales/ja_JP/validation.po でも定義可にしてある）

```/src/Validation/AppValidator.php
<?php
namespace App\Validation;
use Cake\Validation\Validator;

class AppValidator extends Validator
{
	protected $_form = [];
	protected $_messages = [
		'required' => '{0}が入力されていません',
		'integer' => '{0}には数字(整数)を入力してください',
	];
	protected $_errors = [];

	public function __construct($form=[])
	{
		$this->_form = $form;
		return parent::__construct();
	}


	public function validate(array $data, bool $newRecord = true): array
	{
		return $this->_errors = parent::validate($data, $newRecord);
	}

	public function getErrorMessagesArray() {
		$errors = [];
		foreach ($this->_errors as $field => $msgs) {
			foreach ($msgs as $rule => $msg) {
				$errors[] = $msg;
			}
		}
		return $errors;
	}
	public function getErrorMessages($delimiter=PHP_EOL) {
		$errors = $this->getErrorMessagesArray();
		return implode($delimiter, $errors);
	}

    /**
     * Iterates over each rule in the validation set and collects the errors resulting
     * from executing them
     *
     * @param string $field The name of the field that is being processed
     * @param \Cake\Validation\ValidationSet $rules the list of rules for a field
     * @param array $data the full data passed to the validator
     * @param bool $newRecord whether is it a new record or an existing one
     * @return array<string, mixed>
     */
    protected function _processRules(string $field, \Cake\Validation\ValidationSet $rules, array $data, bool $newRecord): array
    {
		$errors = parent::_processRules($field, $rules, $data, $newRecord);
		foreach ($errors as $key => $error) {
			$errors[$key] = $this->getMessage($key, $field) ?? $error;
		}
		return $errors;
    }



    /**
     * Gets the required message for a field
     *
     * @param string $field Field name
     * @return string|null
     */
	public function getRequiredMessage(string $field): ?string
	{
		if (!isset($this->_fields[$field])) {
			return null;
		}

		return $this->getMessage('required', $field);
	}
	
    /**
     * Gets the notEmpty message for a field
     *
     * @param string $field Field name
     * @return string|null
     */
    public function getNotEmptyMessage(string $field): ?string
    {
        if (!isset($this->_fields[$field])) {
            return null;
        }

		return $this->getMessage('required', $field);
    }



	/**
	 * メッセージのキーを取得する。
	 *
	 * @param string $key メッセージのキー
	 * @param null|array $args メッセージに設定する値
	 * @return string|null Translated string.
	 */
	public function getMessage(string $key, string $field)
	{
		$message = $this->_messages[$key] ?? null;
		if (!$message) {
			return null;
		}

		// see \App\Locale\{code}\validation.po
		return __d('validation', $message, $this->_form[$field]['name']);
	}
}
```

上記の共通クラスに基づき、各処理のFormクラスを定義

```php:/src/Form/Account/TestForm.php
<?php
namespace App\Form\Account;

use App\Form\CommonForm;
use Cake\Form\Schema;
use Cake\Validation\Validator;
use App\Validation\AppValidator;

class TestForm extends CommonForm	
{
	protected $_form = array(
		'password' => array(
			'type'	=> 'string',
			'name'	=> 'ID',
			'required'	=> true,
		),
		'password' => array(
			'type'	=> 'string',
			'name'	=> 'パスワード',
			'required'	=> true,
		),
		'number' => array(
			'type'	=> 'integer',
			'name'	=> '数値項目',
			'required'	=> true,
		),
	);

	public function validationDefault(Validator $validator): Validator
	{
		$validator = parent::validationDefault($validator);
		$validator->range('number', [100, 500], '数値が不正です');
		return $validator;
	}

	public function failed() {
		return $this->respondAjaxError();
	}
}
```

## 備考

なんかもっといいやり方ありそうな気はする。ただのメモ