diff --git a/README.md b/README.md index f54f2a2..89cae14 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,43 @@ Or traditionally: $this->addParameterSet(new PaginationParams()); ``` +## Cross-Field Validation + +For validation rules that depend on multiple parameters together, use the `#[Validate]` attribute or override the `validate()` method: + +### Method-Specific Validation (Attribute) + +```php +#[PostMapping] +#[ResponseBody] +#[Validate('validateRegistration')] +#[RequestParam('password', ParamType::STRING)] +#[RequestParam('password_confirm', ParamType::STRING)] +public function register(string $password, string $passwordConfirm): array { ... } + +private function validateRegistration(array $inputs): array { + $errors = []; + if ($inputs['password'] !== $inputs['password_confirm']) { + $errors['password_confirm'] = 'Passwords do not match.'; + } + return $errors; // empty = pass +} +``` + +### Service-Wide Validation (Override) + +```php +public function validate(array $inputs): array { + $errors = []; + if (isset($inputs['end_date']) && $inputs['end_date'] <= $inputs['start_date']) { + $errors['end_date'] = 'End date must be after start date.'; + } + return $errors; +} +``` + +Both run if defined — service-wide first, then method-specific. Errors are merged. If any errors exist, the request returns 422 with the error details. + ## Dynamic Status Codes with ResponseEntity The `ResponseEntity` class allows `#[ResponseBody]` methods to return different HTTP status codes based on runtime logic: diff --git a/WebFiori/Http/Annotations/Validate.php b/WebFiori/Http/Annotations/Validate.php new file mode 100644 index 0000000..8bd20bd --- /dev/null +++ b/WebFiori/Http/Annotations/Validate.php @@ -0,0 +1,41 @@ +runValidation($targetMethod); + + if (!empty($validationErrors)) { + $this->sendResponse('Validation failed', 422, 'error', new \WebFiori\Json\Json([ + 'errors' => $validationErrors + ])); + + return; + } + // Inject parameters into method call $params = $this->getMethodParameters($targetMethod); $result = $this->$targetMethod(...$params); @@ -1326,6 +1352,54 @@ private function configureParametersForHttpMethod(string $httpMethod): void { } } + /** + * Runs cross-field validation: service-wide validate() + method-specific #[Validate]. + * + * @param string $targetMethod The method being invoked. + * + * @return array Merged errors from both validators. Empty if all pass. + */ + private function runValidation(string $targetMethod): array { + $inputs = $this->getInputs(); + + if ($inputs instanceof \WebFiori\Json\Json) { + $inputsArray = []; + + foreach ($inputs->getPropsNames() as $name) { + $inputsArray[$name] = $inputs->get($name); + } + } else { + $inputsArray = is_array($inputs) ? $inputs : []; + } + + // 1. Service-wide validation + $errors = $this->validate($inputsArray); + + // 2. Method-specific #[Validate] attribute + $reflection = new \ReflectionMethod($this, $targetMethod); + $validateAttrs = $reflection->getAttributes(Annotations\Validate::class); + + if (!empty($validateAttrs)) { + $validateAnnotation = $validateAttrs[0]->newInstance(); + $validatorMethod = $validateAnnotation->method; + + if (!method_exists($this, $validatorMethod)) { + throw new \InvalidArgumentException( + "Validation method '$validatorMethod' referenced by #[Validate] does not exist on " . get_class($this) + ); + } + + $validatorReflection = new \ReflectionMethod($this, $validatorMethod); + $validatorReflection->setAccessible(true); + $methodErrors = $validatorReflection->invoke($this, $inputsArray); + + if (is_array($methodErrors)) { + $errors = array_merge($errors, $methodErrors); + } + } + + return $errors; + } /** * Configure parameters from method RequestParam annotations. */ diff --git a/tests/WebFiori/Tests/Http/CrossFieldValidationTest.php b/tests/WebFiori/Tests/Http/CrossFieldValidationTest.php new file mode 100644 index 0000000..b03ed7c --- /dev/null +++ b/tests/WebFiori/Tests/Http/CrossFieldValidationTest.php @@ -0,0 +1,186 @@ +post(new ValidationHookService(), [ + 'email' => 'user@example.com', + 'password' => 'securepass123', + 'password_confirm' => 'securepass123', + ]) + ->assertOk() + ->assertJsonHas('data'); + } + + public function testServiceWideValidationPasses() { + $this->post(new ServiceWideValidationService(), [ + 'start' => '1', + 'end' => '10', + ]) + ->assertOk() + ->assertJsonHas('data'); + } + + // ========================================================================= + // Method-specific #[Validate] failures + // ========================================================================= + + public function testPasswordMismatchFails() { + $this->post(new ValidationHookService(), [ + 'email' => 'user@example.com', + 'password' => 'securepass123', + 'password_confirm' => 'different', + ]) + ->assertStatus(422) + ->assertError() + ->assertJsonHas('more-info'); + } + + public function testPasswordTooShortFails() { + $this->post(new ValidationHookService(), [ + 'email' => 'user@example.com', + 'password' => 'short', + 'password_confirm' => 'short', + ]) + ->assertStatus(422) + ->assertError(); + } + + // ========================================================================= + // Service-wide validate() failures + // ========================================================================= + + public function testServiceWideValidationRejectsInvalidInput() { + $this->post(new ServiceWideValidationService(), [ + 'start' => '10', + 'end' => '5', + ]) + ->assertStatus(422) + ->assertError(); + } + + public function testServiceWidePlusAddressingRejected() { + $this->post(new ValidationHookService(), [ + 'email' => 'user%2Btag@example.com', + 'password' => 'securepass123', + 'password_confirm' => 'securepass123', + ]) + ->assertStatus(422) + ->assertError(); + } + + // ========================================================================= + // Both validators run — errors merged + // ========================================================================= + + public function testBothValidatorsRunErrorsMerged() { + $this->post(new ValidationHookService(), [ + 'email' => 'user%2Btag@example.com', + 'password' => 'short', + 'password_confirm' => 'different', + ]) + ->assertStatus(422) + ->assertError(); + } + + // ========================================================================= + // Missing validator method + // ========================================================================= + + public function testMissingValidatorMethodThrowsException() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('bad-validator'); + $this->addRequestMethod('POST'); + } + #[\WebFiori\Http\Annotations\PostMapping] + #[\WebFiori\Http\Annotations\ResponseBody] + #[\WebFiori\Http\Annotations\AllowAnonymous] + #[\WebFiori\Http\Annotations\Validate('nonExistentMethod')] + #[\WebFiori\Http\Annotations\RequestParam('name', 'string')] + public function doSomething(string $name): array { + return ['name' => $name]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $response = $this->post($service, ['name' => 'test']); + $response->assertBodyContains('nonExistentMethod') + ->assertBodyContains('error'); + } + + // ========================================================================= + // No validation defined — works normally + // ========================================================================= + + public function testNoValidationDefinedWorksNormally() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('no-validation'); + $this->addRequestMethod('POST'); + } + #[\WebFiori\Http\Annotations\PostMapping] + #[\WebFiori\Http\Annotations\ResponseBody] + #[\WebFiori\Http\Annotations\AllowAnonymous] + #[\WebFiori\Http\Annotations\RequestParam('value', 'string')] + public function store(string $value): array { + return ['stored' => $value]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->post($service, ['value' => 'hello']) + ->assertOk() + ->assertJsonHas('data'); + } + + // ========================================================================= + // Validate returns empty array — passes + // ========================================================================= + + public function testValidateReturnsEmptyArrayPasses() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('empty-validate'); + $this->addRequestMethod('POST'); + } + public function validate(array $inputs): array { + return []; // Always passes + } + #[\WebFiori\Http\Annotations\PostMapping] + #[\WebFiori\Http\Annotations\ResponseBody] + #[\WebFiori\Http\Annotations\AllowAnonymous] + #[\WebFiori\Http\Annotations\RequestParam('x', 'string')] + public function process(string $x): array { + return ['x' => $x]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->post($service, ['x' => 'test']) + ->assertOk() + ->assertJsonHas('data'); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ServiceWideValidationService.php b/tests/WebFiori/Tests/Http/TestServices/ServiceWideValidationService.php new file mode 100644 index 0000000..f95592e --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ServiceWideValidationService.php @@ -0,0 +1,44 @@ + $start, 'end' => $end]; + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ValidationHookService.php b/tests/WebFiori/Tests/Http/TestServices/ValidationHookService.php new file mode 100644 index 0000000..a3a9c0a --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ValidationHookService.php @@ -0,0 +1,63 @@ + true, 'email' => $email]; + } + + /** + * Method-specific validator for register(). + */ + private function validateRegistration(array $inputs): array { + $errors = []; + + if ($inputs['password'] !== $inputs['password_confirm']) { + $errors['password_confirm'] = 'Passwords do not match.'; + } + + if (strlen($inputs['password']) < 8) { + $errors['password'] = 'Password must be at least 8 characters.'; + } + + return $errors; + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + } +}