From 7e8c2174fc7cdaa4a3a8f22e6e78c65369f7c3a1 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 25 Jun 2026 16:51:00 +0300 Subject: [PATCH 1/2] feat(annotations): add allowEmpty parameter to #[RequestParam] attribute Add allowEmpty parameter to the RequestParam attribute constructor, allowing annotation-driven services to accept empty strings without falling back to the array-based addParameters() approach. The parameter maps to ParamOption::EMPTY in configureParametersFromMethod(). Closes #148 --- README.md | 15 +++++ WebFiori/Http/Annotations/RequestParam.php | 3 +- WebFiori/Http/WebService.php | 3 + .../Tests/Http/AllowEmptyParamTest.php | 62 +++++++++++++++++++ .../TestServices/AllowEmptyParamService.php | 22 +++++++ 5 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/WebFiori/Tests/Http/AllowEmptyParamTest.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/AllowEmptyParamService.php diff --git a/README.md b/README.md index b6d2ce3..9fd13fe 100644 --- a/README.md +++ b/README.md @@ -424,6 +424,21 @@ public function getData(int $id, ?string $name): array { } ``` +### Allowing Empty Strings + +By default, sending an empty string for a string parameter results in a validation error. Use `allowEmpty: true` in the `#[RequestParam]` attribute to accept empty strings: + +```php +#[PostMapping] +#[ResponseBody] +#[RequestParam(name: 'notes', type: ParamType::STRING, optional: true, allowEmpty: true)] +public function create(?string $notes): array { + return ['notes' => $notes ?? '']; +} +``` + +This is the attribute equivalent of `ParamOption::EMPTY => true` in the array-based approach. + ### Reusable Parameter Sets Implement the `ParameterSet` interface to group related parameters: diff --git a/WebFiori/Http/Annotations/RequestParam.php b/WebFiori/Http/Annotations/RequestParam.php index 3cfbe64..86d3592 100644 --- a/WebFiori/Http/Annotations/RequestParam.php +++ b/WebFiori/Http/Annotations/RequestParam.php @@ -24,7 +24,8 @@ public function __construct( public readonly mixed $filter = null, public readonly array $allowedValues = [], public readonly ?string $pattern = null, - public readonly ?string $message = null + public readonly ?string $message = null, + public readonly bool $allowEmpty = false ) { } } diff --git a/WebFiori/Http/WebService.php b/WebFiori/Http/WebService.php index e97aa2d..e934b2d 100644 --- a/WebFiori/Http/WebService.php +++ b/WebFiori/Http/WebService.php @@ -1641,6 +1641,9 @@ private function configureParametersFromMethod(\ReflectionMethod $method): void if ($param->message !== null) { $options[ParamOption::MESSAGE] = $param->message; } + if ($param->allowEmpty) { + $options[ParamOption::EMPTY] = true; + } $this->addParameters([ $param->name => $options diff --git a/tests/WebFiori/Tests/Http/AllowEmptyParamTest.php b/tests/WebFiori/Tests/Http/AllowEmptyParamTest.php new file mode 100644 index 0000000..21b7917 --- /dev/null +++ b/tests/WebFiori/Tests/Http/AllowEmptyParamTest.php @@ -0,0 +1,62 @@ +addService(new AllowEmptyParamService()); + + $output = $this->postRequest($manager, 'allow-empty-param', [ + 'title' => 'My Title', + 'notes' => '', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('My Title', $response['data']['title']); + $this->assertEquals('', $response['data']['notes']); + } + + /** + * Test that omitting the optional parameter still works. + */ + public function testOmittedOptionalParam() { + $manager = new WebServicesManager(); + $manager->addService(new AllowEmptyParamService()); + + $output = $this->postRequest($manager, 'allow-empty-param', [ + 'title' => 'My Title', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('My Title', $response['data']['title']); + $this->assertEquals('', $response['data']['notes']); + } + + /** + * Test that a non-empty value is accepted normally. + */ + public function testNonEmptyValue() { + $manager = new WebServicesManager(); + $manager->addService(new AllowEmptyParamService()); + + $output = $this->postRequest($manager, 'allow-empty-param', [ + 'title' => 'My Title', + 'notes' => 'Some notes here', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('My Title', $response['data']['title']); + $this->assertEquals('Some notes here', $response['data']['notes']); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/AllowEmptyParamService.php b/tests/WebFiori/Tests/Http/TestServices/AllowEmptyParamService.php new file mode 100644 index 0000000..e368dde --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AllowEmptyParamService.php @@ -0,0 +1,22 @@ + $title, 'notes' => $notes ?? '']; + } +} From 178b52280004859047be83e732502090d6d6dfc3 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 25 Jun 2026 17:10:58 +0300 Subject: [PATCH 2/2] refactor(examples): use RequestProcessor, add testing example, fix class-level AllowAnonymous - Update single-service examples to use RequestProcessor instead of WebServicesManager - Remove unnecessary isAuthorized()/processRequest() boilerplate from annotation examples - Add examples/05-testing with ServiceTestCase usage - Add examples/03-annotations/02-allow-empty demonstrating allowEmpty - Fix checkMethodAuthorization() to respect class-level #[AllowAnonymous] - Update README testing section to use ServiceTestCase --- README.md | 32 ++++----- WebFiori/Http/WebService.php | 5 ++ examples/00-basic/01-hello-world/index.php | 15 ++-- .../00-basic/02-with-parameters/index.php | 15 ++-- .../00-basic/03-multiple-methods/index.php | 15 ++-- .../01-core/01-parameter-validation/index.php | 15 ++-- examples/01-core/02-error-handling/index.php | 15 ++-- examples/01-core/03-json-requests/index.php | 15 ++-- examples/01-core/04-file-uploads/index.php | 15 ++-- .../06-allowed-values-and-pattern/index.php | 7 +- examples/02-security/01-basic-auth/index.php | 15 ++-- .../01-rest-controller/TaskService.php | 12 +--- .../01-rest-controller/index.php | 7 +- .../02-allow-empty/NotesService.php | 34 ++++++++++ .../03-annotations/02-allow-empty/README.md | 41 +++++++++++ .../03-annotations/02-allow-empty/index.php | 9 +++ examples/05-testing/ItemService.php | 47 +++++++++++++ examples/05-testing/ItemServiceTest.php | 38 +++++++++++ examples/05-testing/README.md | 68 +++++++++++++++++++ 19 files changed, 294 insertions(+), 126 deletions(-) create mode 100644 examples/03-annotations/02-allow-empty/NotesService.php create mode 100644 examples/03-annotations/02-allow-empty/README.md create mode 100644 examples/03-annotations/02-allow-empty/index.php create mode 100644 examples/05-testing/ItemService.php create mode 100644 examples/05-testing/ItemServiceTest.php create mode 100644 examples/05-testing/README.md diff --git a/README.md b/README.md index 9fd13fe..93a5c3f 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ With well-established PHP HTTP libraries available, you might wonder why this on - **HTTP Method Support**: Support for all standard HTTP methods (GET, POST, PUT, DELETE, etc.) - **Content Type Handling**: Support for `application/json`, `application/x-www-form-urlencoded`, and `multipart/form-data` - **Object Mapping**: Automatic mapping of request parameters to PHP objects -- **Comprehensive Testing**: Built-in testing utilities with `APITestCase` class +- **Comprehensive Testing**: Built-in testing utilities with `ServiceTestCase` class - **Error Handling**: Structured error responses with appropriate HTTP status codes - **Stream Support**: Custom input/output stream handling for advanced use cases @@ -548,36 +548,30 @@ return new ResponseEntity($body, 418, 'text/plain'); ## Testing -### Using APITestCase +### Using ServiceTestCase ```php addService(new MyService()); - - $response = $this->getRequest($manager, 'my-service', [ + $this->get(new MyService(), [ 'param1' => 'value1', 'param2' => 'value2' - ]); - - $this->assertJson($response); - $this->assertContains('success', $response); + ]) + ->assertOk() + ->assertJson() + ->assertBodyContains('success'); } public function testPostRequest() { - $manager = new WebServicesManager(); - $manager->addService(new MyService()); - - $response = $this->postRequest($manager, 'my-service', [ + $this->post(new MyService(), [ 'name' => 'John Doe', 'email' => 'john@example.com' - ]); - - $this->assertJson($response); + ]) + ->assertOk() + ->assertJson(); } } ``` diff --git a/WebFiori/Http/WebService.php b/WebFiori/Http/WebService.php index e934b2d..e8c5728 100644 --- a/WebFiori/Http/WebService.php +++ b/WebFiori/Http/WebService.php @@ -372,6 +372,11 @@ public function checkMethodAuthorization(): bool { return SecurityContext::isAuthenticated(); } + // If class has #[AllowAnonymous], allow access + if (!$this->isAuthRequired()) { + return true; + } + return $this->isAuthorized(); } diff --git a/examples/00-basic/01-hello-world/index.php b/examples/00-basic/01-hello-world/index.php index cdf1d15..28b98c6 100644 --- a/examples/00-basic/01-hello-world/index.php +++ b/examples/00-basic/01-hello-world/index.php @@ -1,16 +1,9 @@ setVersion('1.0.0'); -$manager->setDescription('Hello World API Example'); - -// Auto-discover and register services -$manager->autoDiscoverServices(); - -// Process the incoming request -$manager->process(); +$processor = new RequestProcessor(); +$processor->process(new HelloService()); diff --git a/examples/00-basic/02-with-parameters/index.php b/examples/00-basic/02-with-parameters/index.php index 1e4e172..95b02c9 100644 --- a/examples/00-basic/02-with-parameters/index.php +++ b/examples/00-basic/02-with-parameters/index.php @@ -1,16 +1,9 @@ setVersion('1.0.0'); -$manager->setDescription('Greeting API with Parameters'); - -// Auto-discover and register services -$manager->autoDiscoverServices(); - -// Process the incoming request -$manager->process(); +$processor = new RequestProcessor(); +$processor->process(new GreetingService()); diff --git a/examples/00-basic/03-multiple-methods/index.php b/examples/00-basic/03-multiple-methods/index.php index a1ae0c0..2f8cec9 100644 --- a/examples/00-basic/03-multiple-methods/index.php +++ b/examples/00-basic/03-multiple-methods/index.php @@ -1,16 +1,9 @@ setVersion('1.0.0'); -$manager->setDescription('Task Management API'); - -// Auto-discover and register services -$manager->autoDiscoverServices(); - -// Process the incoming request -$manager->process(); +$processor = new RequestProcessor(); +$processor->process(new TaskService()); diff --git a/examples/01-core/01-parameter-validation/index.php b/examples/01-core/01-parameter-validation/index.php index 9a55138..3c7700a 100644 --- a/examples/01-core/01-parameter-validation/index.php +++ b/examples/01-core/01-parameter-validation/index.php @@ -1,16 +1,9 @@ setVersion('1.0.0'); -$manager->setDescription('Parameter Validation API'); - -// Auto-discover and register services -$manager->autoDiscoverServices(); - -// Process the incoming request -$manager->process(); +$processor = new RequestProcessor(); +$processor->process(new ValidationService()); diff --git a/examples/01-core/02-error-handling/index.php b/examples/01-core/02-error-handling/index.php index b419819..c97f18b 100644 --- a/examples/01-core/02-error-handling/index.php +++ b/examples/01-core/02-error-handling/index.php @@ -1,16 +1,9 @@ setVersion('1.0.0'); -$manager->setDescription('Error Handling API'); - -// Auto-discover and register services -$manager->autoDiscoverServices(); - -// Process the incoming request -$manager->process(); +$processor = new RequestProcessor(); +$processor->process(new ErrorService()); diff --git a/examples/01-core/03-json-requests/index.php b/examples/01-core/03-json-requests/index.php index 3dadb31..aea36c1 100644 --- a/examples/01-core/03-json-requests/index.php +++ b/examples/01-core/03-json-requests/index.php @@ -1,16 +1,9 @@ setVersion('1.0.0'); -$manager->setDescription('JSON Request Handling API'); - -// Auto-discover and register services -$manager->autoDiscoverServices(); - -// Process the incoming request -$manager->process(); +$processor = new RequestProcessor(); +$processor->process(new JsonService()); diff --git a/examples/01-core/04-file-uploads/index.php b/examples/01-core/04-file-uploads/index.php index fb4fc16..0076f50 100644 --- a/examples/01-core/04-file-uploads/index.php +++ b/examples/01-core/04-file-uploads/index.php @@ -1,16 +1,9 @@ setVersion('1.0.0'); -$manager->setDescription('File Upload API'); - -// Auto-discover and register services -$manager->autoDiscoverServices(); - -// Process the incoming request -$manager->process(); +$processor = new RequestProcessor(); +$processor->process(new UploadService()); diff --git a/examples/01-core/06-allowed-values-and-pattern/index.php b/examples/01-core/06-allowed-values-and-pattern/index.php index dc460c6..390a039 100644 --- a/examples/01-core/06-allowed-values-and-pattern/index.php +++ b/examples/01-core/06-allowed-values-and-pattern/index.php @@ -3,8 +3,7 @@ require_once '../../../vendor/autoload.php'; require_once 'OrderService.php'; -use WebFiori\Http\WebServicesManager; +use WebFiori\Http\RequestProcessor; -$manager = new WebServicesManager(); -$manager->addService(new OrderService()); -$manager->process(); +$processor = new RequestProcessor(); +$processor->process(new OrderService()); diff --git a/examples/02-security/01-basic-auth/index.php b/examples/02-security/01-basic-auth/index.php index 88b28bf..2f7b1a5 100644 --- a/examples/02-security/01-basic-auth/index.php +++ b/examples/02-security/01-basic-auth/index.php @@ -1,16 +1,9 @@ setVersion('1.0.0'); -$manager->setDescription('Basic Authentication API'); - -// Auto-discover and register services -$manager->autoDiscoverServices(); - -// Process the incoming request -$manager->process(); +$processor = new RequestProcessor(); +$processor->process(new BasicAuthService()); diff --git a/examples/03-annotations/01-rest-controller/TaskService.php b/examples/03-annotations/01-rest-controller/TaskService.php index 77144aa..8cb7d06 100644 --- a/examples/03-annotations/01-rest-controller/TaskService.php +++ b/examples/03-annotations/01-rest-controller/TaskService.php @@ -20,6 +20,7 @@ * - Dynamic HTTP status codes via ResponseEntity */ #[RestController('tasks', 'Task management service')] +#[AllowAnonymous] class TaskService extends WebService { private array $tasks = [ @@ -30,11 +31,9 @@ class TaskService extends WebService { #[GetMapping] #[ResponseBody] - #[AllowAnonymous] #[RequestParam('task-id', ParamType::INT, true)] public function getTask(?int $id): ResponseEntity { if ($id === null) { - // Return all tasks return ResponseEntity::ok(new Json(['tasks' => array_values($this->tasks)])); } @@ -47,7 +46,6 @@ public function getTask(?int $id): ResponseEntity { #[PostMapping] #[ResponseBody] - #[AllowAnonymous] #[RequestParam('task-name', ParamType::STRING)] #[RequestParam('task-priority', ParamType::STRING, true)] public function createTask(string $name, ?string $priority): ResponseEntity { @@ -63,7 +61,6 @@ public function createTask(string $name, ?string $priority): ResponseEntity { #[DeleteMapping] #[ResponseBody] - #[AllowAnonymous] #[RequestParam('task-id', ParamType::INT)] public function deleteTask(int $id): ResponseEntity { if (!isset($this->tasks[$id])) { @@ -72,11 +69,4 @@ public function deleteTask(int $id): ResponseEntity { return ResponseEntity::noContent(); } - - public function isAuthorized(): bool { - return true; - } - - public function processRequest() { - } } diff --git a/examples/03-annotations/01-rest-controller/index.php b/examples/03-annotations/01-rest-controller/index.php index 787addd..bd7587f 100644 --- a/examples/03-annotations/01-rest-controller/index.php +++ b/examples/03-annotations/01-rest-controller/index.php @@ -3,8 +3,7 @@ require_once '../../../vendor/autoload.php'; require_once 'TaskService.php'; -use WebFiori\Http\WebServicesManager; +use WebFiori\Http\RequestProcessor; -$manager = new WebServicesManager(); -$manager->addService(new TaskService()); -$manager->process(); +$processor = new RequestProcessor(); +$processor->process(new TaskService()); diff --git a/examples/03-annotations/02-allow-empty/NotesService.php b/examples/03-annotations/02-allow-empty/NotesService.php new file mode 100644 index 0000000..41e8ec7 --- /dev/null +++ b/examples/03-annotations/02-allow-empty/NotesService.php @@ -0,0 +1,34 @@ + $title, + 'notes' => $notes ?? '', + 'created_at' => date('Y-m-d H:i:s'), + ]; + } +} diff --git a/examples/03-annotations/02-allow-empty/README.md b/examples/03-annotations/02-allow-empty/README.md new file mode 100644 index 0000000..e82678a --- /dev/null +++ b/examples/03-annotations/02-allow-empty/README.md @@ -0,0 +1,41 @@ +# Allow Empty Strings with `#[RequestParam]` + +This example demonstrates using `allowEmpty: true` in the `#[RequestParam]` attribute to accept empty string values without triggering a validation error. + +## The Problem + +By default, sending an empty string `""` for a string parameter results in a 422 validation error. This is intentional — most string parameters should reject empty input. However, some fields (like optional notes or descriptions) should legitimately accept empty strings. + +## The Solution + +Use the `allowEmpty` named parameter: + +```php +#[RequestParam(name: 'notes', type: ParamType::STRING, optional: true, allowEmpty: true)] +``` + +## Running + +```bash +cd examples/03-annotations/02-allow-empty +php -S localhost:8080 index.php +``` + +## Testing + +```bash +# With a non-empty value +curl -X POST http://localhost:8080 \ + -H "Content-Type: application/json" \ + -d '{"title": "My Note", "notes": "Some content"}' + +# With an empty string (accepted because of allowEmpty: true) +curl -X POST http://localhost:8080 \ + -H "Content-Type: application/json" \ + -d '{"title": "My Note", "notes": ""}' + +# Without the notes field (accepted because optional: true) +curl -X POST http://localhost:8080 \ + -H "Content-Type: application/json" \ + -d '{"title": "My Note"}' +``` diff --git a/examples/03-annotations/02-allow-empty/index.php b/examples/03-annotations/02-allow-empty/index.php new file mode 100644 index 0000000..b99c58d --- /dev/null +++ b/examples/03-annotations/02-allow-empty/index.php @@ -0,0 +1,9 @@ +process(new NotesService()); diff --git a/examples/05-testing/ItemService.php b/examples/05-testing/ItemService.php new file mode 100644 index 0000000..e7fac3b --- /dev/null +++ b/examples/05-testing/ItemService.php @@ -0,0 +1,47 @@ + [ + ['id' => 1, 'name' => 'Widget'], + ['id' => 2, 'name' => 'Gadget'], + ]]; + } + + if ($id > 2) { + return ['error' => 'Item not found']; + } + + return ['id' => $id, 'name' => 'Widget']; + } + + #[PostMapping] + #[ResponseBody] + #[RequestParam('name', ParamType::STRING)] + #[RequestParam('price', ParamType::DOUBLE)] + public function createItem(string $name, float $price): array { + return [ + 'id' => 3, + 'name' => $name, + 'price' => $price, + ]; + } +} diff --git a/examples/05-testing/ItemServiceTest.php b/examples/05-testing/ItemServiceTest.php new file mode 100644 index 0000000..a7b5c0a --- /dev/null +++ b/examples/05-testing/ItemServiceTest.php @@ -0,0 +1,38 @@ +get(new ItemService()) + ->assertOk() + ->assertJson() + ->assertJsonHas('data') + ->assertBodyContains('items'); + } + + public function testGetSingleItem() { + $this->get(new ItemService(), ['id' => 1]) + ->assertOk() + ->assertJson() + ->assertBodyContains('Widget'); + } + + public function testCreateItem() { + $this->post(new ItemService(), ['name' => 'Doohickey', 'price' => 9.99]) + ->assertOk() + ->assertJson() + ->assertBodyContains('Doohickey'); + } + + public function testCreateItemMissingParam() { + $this->post(new ItemService(), ['name' => 'Incomplete']) + ->assertError() + ->assertJson() + ->assertBodyContains('price'); + } +} diff --git a/examples/05-testing/README.md b/examples/05-testing/README.md new file mode 100644 index 0000000..467d208 --- /dev/null +++ b/examples/05-testing/README.md @@ -0,0 +1,68 @@ +# Testing Web Services with `ServiceTestCase` + +This example demonstrates how to test web services using the built-in `ServiceTestCase` class, which provides a clean, fluent API for sending requests and asserting responses. + +## Files + +- `ItemService.php` — A simple CRUD service to test against +- `ItemServiceTest.php` — PHPUnit test class using `ServiceTestCase` + +## Key Concepts + +### Extending `ServiceTestCase` + +```php +use WebFiori\Http\Test\ServiceTestCase; + +class ItemServiceTest extends ServiceTestCase { + public function testGetItems() { + $this->get(new ItemService()) + ->assertOk() + ->assertJson() + ->assertJsonHas('items'); + } +} +``` + +### Available Request Methods + +| Method | Description | +|:-------|:------------| +| `$this->get($service, $params)` | Send a GET request | +| `$this->post($service, $params)` | Send a POST request | +| `$this->put($service, $params)` | Send a PUT request | +| `$this->patch($service, $params)` | Send a PATCH request | +| `$this->delete($service, $params)` | Send a DELETE request | +| `$this->call($method, $service, $params)` | Send any HTTP method | + +### Available Assertions (fluent, chainable) + +| Assertion | Description | +|:----------|:------------| +| `->assertOk()` | Status 200 | +| `->assertStatus(201)` | Specific status code | +| `->assertUnauthorized()` | Status 401 | +| `->assertNotFound()` | Status 404 | +| `->assertMethodNotAllowed()` | Status 405 | +| `->assertError()` | Status 4xx or 5xx | +| `->assertJson()` | Response is valid JSON | +| `->assertJsonHas('key')` | JSON contains key | +| `->assertJsonEquals('key', $val)` | JSON key equals value | +| `->assertBodyContains('text')` | Raw body contains string | + +### Testing with Authentication + +Pass a `SecurityPrincipal` as the third argument: + +```php +$user = new MyPrincipal('admin', ['ADMIN']); +$this->get(new ProtectedService(), [], $user) + ->assertOk(); +``` + +## Running + +```bash +cd examples/05-testing +../../vendor/bin/phpunit ItemServiceTest.php +```