Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
41 changes: 41 additions & 0 deletions WebFiori/Http/Annotations/Validate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

/**
* This file is licensed under MIT License.
*
* Copyright (c) 2026-present WebFiori Framework
*
* For more information on the license, please visit:
* https://github.com/WebFiori/.github/blob/main/LICENSE
*/
namespace WebFiori\Http\Annotations;

use Attribute;

/**
* Specifies a method-specific cross-field validation function.
*
* The referenced method must exist on the service class, accept an array
* of filtered inputs, and return an array of errors (empty = valid).
*
* Usage:
* ```php
* #[Validate('validateRegistration')]
* public function register(...): array { ... }
*
* private function validateRegistration(array $inputs): array {
* $errors = [];
* if ($inputs['password'] !== $inputs['password_confirm']) {
* $errors['password_confirm'] = 'Passwords do not match.';
* }
* return $errors;
* }
* ```
*/
#[Attribute(Attribute::TARGET_METHOD)]
class Validate {
public function __construct(
public readonly string $method
) {
}
}
74 changes: 74 additions & 0 deletions WebFiori/Http/WebService.php
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,21 @@
*/
public function processRequest() {
}
/**
* Service-wide cross-field validation hook.
*
* Override this method to add validation rules that depend on multiple
* parameters together. Called after individual parameter validation passes
* but before the request method is invoked.
*
* @param array $inputs The filtered input values.
*
* @return array An associative array of errors keyed by field name.
* Return empty array if validation passes.
*/
public function validate(array $inputs): array {
return [];
}

/**
* Process the web service request with auto-processing support.
Expand All @@ -752,6 +767,17 @@
}

try {
// Run cross-field validation
$validationErrors = $this->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);
Expand Down Expand Up @@ -1326,6 +1352,54 @@
}
}

/**
* 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);

Check warning on line 1393 in WebFiori/Http/WebService.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure that this accessibility update is safe here.

See more on https://sonarcloud.io/project/issues?id=WebFiori_http2&issues=AZ6D6r4d7xGdF9PNuX_8&open=AZ6D6r4d7xGdF9PNuX_8&pullRequest=134
$methodErrors = $validatorReflection->invoke($this, $inputsArray);

if (is_array($methodErrors)) {
$errors = array_merge($errors, $methodErrors);
}
}

return $errors;
}
/**
* Configure parameters from method RequestParam annotations.
*/
Expand Down
186 changes: 186 additions & 0 deletions tests/WebFiori/Tests/Http/CrossFieldValidationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php
namespace WebFiori\Tests\Http;

use WebFiori\Http\Test\ServiceTestCase;
use WebFiori\Tests\Http\TestServices\ValidationHookService;
use WebFiori\Tests\Http\TestServices\ServiceWideValidationService;

/**
* Tests for cross-field validation hook (#115).
*
* Covers:
* - Service-wide validate() method
* - Method-specific #[Validate] attribute
* - Both running together with merged errors
* - Missing validator method throws exception
* - Validation passes → method invoked
*/
class CrossFieldValidationTest extends ServiceTestCase {

// =========================================================================
// Happy path — validation passes
// =========================================================================

public function testValidationPassesAllowsExecution() {
$this->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');
}
}
Loading
Loading