diff --git a/README.md b/README.md index b6d2ce37..93a5c3fb 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 @@ -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: @@ -533,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/Annotations/RequestParam.php b/WebFiori/Http/Annotations/RequestParam.php index 3cfbe648..86d35923 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 e97aa2dd..e8c5728a 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(); } @@ -1641,6 +1646,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/examples/00-basic/01-hello-world/index.php b/examples/00-basic/01-hello-world/index.php index cdf1d15a..28b98c6e 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 1e4e172c..95b02c95 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 a1ae0c00..2f8cec90 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 9a551383..3c7700aa 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 b4198193..c97f18b9 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 3dadb31a..aea36c15 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 fb4fc165..0076f501 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 dc460c63..390a0393 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 88b28bf4..2f7b1a5d 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 77144aa2..8cb7d060 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 787addd1..bd7587f1 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 00000000..41e8ec73 --- /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 00000000..e82678a4 --- /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 00000000..b99c58d2 --- /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 00000000..e7fac3bb --- /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 00000000..a7b5c0ab --- /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 00000000..467d2080 --- /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 +``` diff --git a/tests/WebFiori/Tests/Http/AllowEmptyParamTest.php b/tests/WebFiori/Tests/Http/AllowEmptyParamTest.php new file mode 100644 index 00000000..21b7917a --- /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 00000000..e368dde5 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AllowEmptyParamService.php @@ -0,0 +1,22 @@ + $title, 'notes' => $notes ?? '']; + } +}