NoraLib Validation API Documentation

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]
        );
    }
}

重要なポイント

インターセプターの実行順序は以下の通りです:

  1. Ray.Validationのインターセプター( @Valid を処理)
  2. 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();
    // ...
}

実行フロー

  1. クライアントがリクエスト送信
  2. Ray.Validationのインターセプターが @Valid を検出
  3. onValidatePost(mixed $userData) が実行(型エラー回避)
  4. 型チェック → NoraLib Validationで詳細チェック
  5. エラーがあれば ExceptionGroup を投げる
  6. JsonRestInterceptorの catch ブロックで ExceptionGroup をキャッチ
  7. ValidationalError に変換して構造化レスポンスを返す
  8. エラーがなければ 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設計
  • 再利用性 : ポリシーベースでバリデーションルールを共有
  • 保守性 : バリデーションロジックの一元管理
  • テスト容易性 : 各コンポーネントを独立してテスト可能

推奨パターン

  1. 小〜中規模API : このJsonRestInterceptorパターン
  2. 大規模API : ポリシーベース + ValidationHelperトレイト
  3. マイクロサービス : DIコンテナでValidatorを共有

次のステップ

Search results