BEAR.Sunday統合ガイド
NoraLib ValidationをBEAR.Sundayアプリケーションで使用する方法を説明します。
概要
BEAR.Sundayでは、AOPインターセプターを使用してバリデーションを実装します。NoraLib Validationは、Ray.Validationと組み合わせることで、詳細なエラー情報と統一されたAPIレスポンスを実現できます。
推奨アプローチ: JsonRestInterceptorパターン
このパターンは以下の特徴があります:
- Ray.Validationの
@OnValidateで型安全性を確保 - NoraLib Validationの詳細なエラー情報を活用
- 統一されたJSONレスポンス構造
- エラーレベル別のログ出力
必要なクラス定義
ResponseInfoStatus
enum ResponseInfoStatus: string
{
case Ok = 'ok';
case Err = 'error';
}
ResponseInfo
readonly class ResponseInfo implements JsonSerializable
{
public function __construct(
public ResponseInfoStatus $status,
public ?string $message = null
) {}
public function jsonSerialize(): array
{
$data = ['status' => $this->status->value];
if ($this->message !== null) {
$data['message'] = $this->message;
}
return $data;
}
}
JsonRestアノテーション
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class JsonRest
{
public function __construct(
private bool $normalizeOutput = true
) {}
public function normalizeOutput(): bool
{
return $this->normalizeOutput;
}
}
JsonRestInterceptor
統一されたJSONレスポンスとエラーハンドリングを提供するインターセプターです:
use Ray\Aop\MethodInterceptor;
use Ray\Aop\MethodInvocation;
use BEAR\Resource\ResourceObject;
use NoraLib\Validation\Exception\ValidationalError;
use NoraLib\Validation\Rule\Exception\ExceptionGroup;
use Psr\Log\LoggerInterface;
readonly class JsonRestInterceptor implements MethodInterceptor
{
public function __construct(
private LoggerInterface $logger
) {}
public function invoke(MethodInvocation $invocation)
{
$annotation = $invocation->getMethod()->getAnnotation(JsonRest::class);
if (!$annotation || $annotation->normalizeOutput() === false) {
return $invocation->proceed();
}
try {
$invocation->proceed();
/** @var ResourceObject $ro */
$ro = $invocation->getThis();
$ro->body = [
"info" => new ResponseInfo(
status: ResponseInfoStatus::Ok,
),
"payload" => $ro->body ?? []
];
return $ro;
} catch (\Throwable $e) {
$ro = $invocation->getThis();
// バリデーションエラー(ExceptionGroup または ValidationalError)
if ($e instanceof ValidationalError || $e instanceof ExceptionGroup) {
if ($e instanceof ExceptionGroup) {
$e = ValidationalError::createFromValidator($e);
}
$ro->code = 422;
$ro->body = [
"info" => new ResponseInfo(
status: ResponseInfoStatus::Err,
message: $e->getMessage(),
),
"payload" => $e->buildPayload()
];
$this->logger->info($e->getMessage());
return $ro;
}
// その他のエラー
if ($e instanceof UserRuntimeError) {
$ro->code = $e->getCode();
} else {
$ro->code = 500;
}
if ($ro->code === 401) {
$this->logger->debug($e->getMessage());
} else {
$this->logger->error($e->getMessage(), [
"trace" => (string)$e,
]);
}
$ro->body = [
"info" => new ResponseInfo(
status: ResponseInfoStatus::Err,
message: $e->getMessage()
),
"payload" => []
];
return $ro;
}
}
}
Module設定
AppModuleでインターセプターを登録します:
use Ray\Di\AbstractModule;
use Ray\Validation\ValidateModule;
class AppModule extends AbstractModule
{
protected function configure()
{
// 1. Ray.Validationモジュール(@Validを処理)
$this->install(new ValidateModule);
// 2. JsonRestInterceptor(@JsonRestを処理)
$this->bindInterceptor(
$this->matcher->any(),
$this->matcher->annotatedWith(JsonRest::class),
[JsonRestInterceptor::class]
);
}
}
重要なポイント
インターセプターの実行順序は以下の通りです:
- Ray.Validationのインターセプター(
@Validを処理) - JsonRestInterceptor(
@JsonRestを処理)
この順序により、型エラーが発生する前にバリデーションが実行されます。
Resource実装
基本的な実装例
use BEAR\Resource\ResourceObject;
use Ray\Validation\Annotation\Valid;
use Ray\Validation\Annotation\OnValidate;
use NoraLib\Validation\Validator;
use NoraLib\Validation\Rule\Exception\ExceptionGroup;
class Users extends ResourceObject
{
/**
* @Valid
*/
#[JsonRest]
public function onPost(array $userData): static
{
// ここに来る時点でバリデーション済み
$userId = $this->createUser($userData);
$this->code = 201;
$this->body = [
'id' => $userId,
'email' => $userData['email']
];
return $this;
}
/**
* @OnValidate
*/
public function onValidatePost(mixed $userData): \Ray\Validation\Validation
{
$validation = new \Ray\Validation\Validation();
// Step 1: 基本的な型チェック
if (!is_array($userData)) {
throw new \NoraLib\Validation\Exception\ValidationalError(
input_key: 'userData',
message: 'Request body must be an array'
);
}
// Step 2: NoraLib Validationで詳細チェック
$validator = new Validator();
$userValidator = $validator->array([
'email' => $validator->validEmail(),
'password' => $validator
->notEmpty()
->length(min: 12)
->passwordComplexity(4)
->passwordNoCommonWord(),
]);
// ExceptionGroupを投げる → JsonRestInterceptorがキャッチ
$userValidator->assert($userData);
return $validation; // エラーなし
}
private function createUser(array $userData): int
{
// ユーザー作成処理
return 123;
}
}
型エラー対策の重要ポイント
問題:
onPost(array $userData)
のような型付き引数は、不正な型が渡されると TypeError
が発生し、メソッド本体に到達しません。
解決策:
@OnValidate
メソッドの引数を mixed
にすることで、型エラーを回避します:
/**
* @OnValidate
*/
public function onValidatePost(mixed $userData): \Ray\Validation\Validation
{
// まず型チェック
if (!is_array($userData)) {
throw new ValidationalError(
input_key: 'userData',
message: 'Request body must be an array'
);
}
// 次にNoraLib Validationで詳細チェック
$validator = new Validator();
// ...
}
実行フロー
- クライアントがリクエスト送信
- Ray.Validationのインターセプターが
@Validを検出 onValidatePost(mixed $userData)が実行(型エラー回避)- 型チェック → NoraLib Validationで詳細チェック
- エラーがあれば
ExceptionGroupを投げる - JsonRestInterceptorの
catchブロックでExceptionGroupをキャッチ ValidationalErrorに変換して構造化レスポンスを返す- エラーがなければ
onPost()が実行される
ネストされたデータのバリデーション
/**
* @OnValidate
*/
public function onValidatePost(mixed $userData): \Ray\Validation\Validation
{
if (!is_array($userData)) {
throw new ValidationalError(
input_key: 'userData',
message: 'Request body must be an array'
);
}
$validator = new Validator();
$userValidator = $validator->array([
'email' => $validator->validEmail(),
'password' => $validator
->notEmpty()
->length(min: 12)
->passwordComplexity(4)
->passwordNoCommonWord(),
'profile' => $validator->array([
'name' => $validator->notEmpty()->length(min: 2, max: 50),
'age' => $validator->numeric(),
'address' => $validator->array([
'zipCode' => $validator->regex('/^\d{3}-\d{4}$/'),
'prefecture' => $validator->notEmpty(),
]),
]),
]);
$userValidator->assert($userData);
return new \Ray\Validation\Validation();
}
レスポンス例
成功時
HTTP 201 Created
{
"info": {
"status": "ok"
},
"payload": {
"id": 123,
"email": "user@example.com"
}
}
バリデーションエラー時
HTTP 422 Unprocessable Entity
{
"info": {
"status": "error",
"message": "Validation failed"
},
"payload": {
"description": [
"Invalid email format",
"Password must be at least 12 characters"
],
"errors": [
{
"input": "email",
"label": "email",
"message": "Invalid email format"
},
{
"input": "password",
"label": "password",
"message": "Password must be at least 12 characters"
}
]
}
}
ネストされたフィールドのエラー例
{
"info": {
"status": "error",
"message": "Validation failed"
},
"payload": {
"description": [
"Name is required",
"Invalid zip code format"
],
"errors": [
{
"input": "profile.name",
"label": "profile.name",
"message": "Name is required"
},
{
"input": "profile.address.zipCode",
"label": "profile.address.zipCode",
"message": "Invalid zip code format"
}
]
}
}
型エラー時
HTTP 422 Unprocessable Entity
{
"info": {
"status": "error",
"message": "Request body must be an array"
},
"payload": {
"description": [
"Request body must be an array"
],
"errors": [
{
"input": "userData",
"label": "userData",
"message": "Request body must be an array"
}
]
}
}
ValidationHelperトレイト(オプション)
型チェックを頻繁に行う場合は、ヘルパートレイトを作成すると便利です:
trait ValidationHelper
{
protected function validateType(
mixed $value,
string $expectedType,
string $fieldName
): void {
$valid = match($expectedType) {
'array' => is_array($value),
'string' => is_string($value),
'int' => is_int($value),
'bool' => is_bool($value),
default => true
};
if (!$valid) {
throw new \NoraLib\Validation\Exception\ValidationalError(
input_key: $fieldName,
message: sprintf(
'%s must be %s, %s given',
$fieldName,
$expectedType,
get_debug_type($value)
)
);
}
}
}
使用例:
class Users extends ResourceObject
{
use ValidationHelper;
/**
* @OnValidate
*/
public function onValidatePost(mixed $userData): \Ray\Validation\Validation
{
$this->validateType($userData, 'array', 'userData');
$validator = new Validator();
$userValidator = $validator->array([...]);
$userValidator->assert($userData);
return new \Ray\Validation\Validation();
}
}
再利用可能なポリシー
複数のリソースで共通のバリデーションルールを使用する場合は、ポリシーを定義します:
use NoraLib\Validation\Validator;
use NoraLib\Validation\Rule\{
NotEmptyRule,
ValidEmailRule,
LengthRule,
PasswordComplexityRule,
PasswordNoCommonWordRule
};
class ValidationPolicies
{
public static function setup(Validator $validator): void
{
// ユーザーメールポリシー
$validator->setPolicy('user_email', [
new NotEmptyRule(),
new ValidEmailRule(),
]);
// セキュアパスワードポリシー
$validator->setPolicy('secure_password', [
new NotEmptyRule(),
new LengthRule(12, 128),
new PasswordComplexityRule(4),
new PasswordNoCommonWordRule(),
]);
// ユーザー名ポリシー
$validator->setPolicy('username', [
new NotEmptyRule(),
new LengthRule(3, 20),
new RegexRule('/^[a-zA-Z0-9_]+$/', '英数字とアンダースコアのみ'),
]);
}
}
DIコンテナでValidatorを共有:
use Ray\Di\AbstractModule;
use Ray\Di\ProviderInterface;
class AppModule extends AbstractModule
{
protected function configure()
{
$this->bind(Validator::class)->toProvider(ValidatorProvider::class);
$this->install(new ValidateModule);
$this->bindInterceptor(
$this->matcher->any(),
$this->matcher->annotatedWith(JsonRest::class),
[JsonRestInterceptor::class]
);
}
}
class ValidatorProvider implements ProviderInterface
{
public function get(): Validator
{
$validator = new Validator();
ValidationPolicies::setup($validator);
return $validator;
}
}
ポリシーを使用したバリデーション:
class Users extends ResourceObject
{
public function __construct(
private Validator $validator
) {}
/**
* @OnValidate
*/
public function onValidatePost(mixed $userData): \Ray\Validation\Validation
{
if (!is_array($userData)) {
throw new ValidationalError(
input_key: 'userData',
message: 'Request body must be an array'
);
}
$userValidator = $this->validator->array([
'email' => $this->validator->policy('user_email'),
'password' => $this->validator->policy('secure_password'),
'username' => $this->validator->policy('username'),
]);
$userValidator->assert($userData);
return new \Ray\Validation\Validation();
}
}
テスト
use PHPUnit\Framework\TestCase;
class UsersResourceTest extends TestCase
{
private Users $resource;
protected function setUp(): void
{
$this->resource = new Users();
}
public function testValidUserData(): void
{
$validation = $this->resource->onValidatePost([
'email' => 'user@example.com',
'password' => 'SecurePass123!@#',
]);
$this->assertFalse($validation->hasError());
}
public function testInvalidEmail(): void
{
$this->expectException(\NoraLib\Validation\Rule\Exception\ExceptionGroup::class);
$this->resource->onValidatePost([
'email' => 'invalid-email',
'password' => 'SecurePass123!@#',
]);
}
public function testTypeError(): void
{
$this->expectException(\NoraLib\Validation\Exception\ValidationalError::class);
$this->expectExceptionMessage('Request body must be an array');
$this->resource->onValidatePost('not-an-array');
}
}
まとめ
このアプローチの利点
- 型安全性
:
@OnValidateで型エラーを事前に防止 - 詳細なエラー情報 : NoraLib Validationの構造化ペイロードを活用
- 統一されたレスポンス : JsonRestInterceptorで一貫したAPI設計
- 再利用性 : ポリシーベースでバリデーションルールを共有
- 保守性 : バリデーションロジックの一元管理
- テスト容易性 : 各コンポーネントを独立してテスト可能
推奨パターン
- 小〜中規模API : このJsonRestInterceptorパターン
- 大規模API : ポリシーベース + ValidationHelperトレイト
- マイクロサービス : DIコンテナでValidatorを共有