From 2d0e6fc0bdbbb85d8eef41ecaaec477bc8be35cb Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 18 Dec 2025 17:06:49 +0300 Subject: [PATCH 01/21] feat: Added Annotations to Web Services --- WebFiori/Http/Annotations/RestController.php | 12 ++++ WebFiori/Http/WebService.php | 31 ++++++++-- examples/AnnotatedService.php | 27 +++++++++ .../Tests/Http/RestControllerTest.php | 57 +++++++++++++++++++ .../Http/TestServices/AnnotatedService.php | 16 ++++++ .../Http/TestServices/NonAnnotatedService.php | 19 +++++++ 6 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 WebFiori/Http/Annotations/RestController.php create mode 100644 examples/AnnotatedService.php create mode 100644 tests/WebFiori/Tests/Http/RestControllerTest.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php diff --git a/WebFiori/Http/Annotations/RestController.php b/WebFiori/Http/Annotations/RestController.php new file mode 100644 index 0000000..a1354b1 --- /dev/null +++ b/WebFiori/Http/Annotations/RestController.php @@ -0,0 +1,12 @@ +setName($name)) { - $this->setName('new-service'); - } + public function __construct(string $name = '') { $this->reqMethods = []; $this->parameters = []; $this->responses = []; @@ -138,8 +135,34 @@ public function __construct(string $name) { $this->sinceVersion = '1.0.0'; $this->serviceDesc = ''; $this->request = Request::createFromGlobals(); + + $this->configureFromAnnotations($name); } + /** + * Configure service from annotations if present. + */ + private function configureFromAnnotations(string $fallbackName): void { + $reflection = new \ReflectionClass($this); + $attributes = $reflection->getAttributes(\WebFiori\Http\Annotations\RestController::class); + + if (!empty($attributes)) { + $restController = $attributes[0]->newInstance(); + $serviceName = $restController->name ?: $fallbackName; + $description = $restController->description; + } else { + $serviceName = $fallbackName; + $description = ''; + } + + if (!$this->setName($serviceName)) { + $this->setName('new-service'); + } + + if ($description) { + $this->setDescription($description); + } + } /** * Returns an array that contains all possible requests methods at which the * service can be called with. * diff --git a/examples/AnnotatedService.php b/examples/AnnotatedService.php new file mode 100644 index 0000000..e46cdfd --- /dev/null +++ b/examples/AnnotatedService.php @@ -0,0 +1,27 @@ +setRequestMethods([RequestMethod::GET]); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $this->sendResponse('Hello from annotated service!'); + } +} + +// Usage example +$service = new AnnotatedHelloService(); +echo "Service name: " . $service->getName() . "\n"; +echo "Service description: " . $service->getDescription() . "\n"; diff --git a/tests/WebFiori/Tests/Http/RestControllerTest.php b/tests/WebFiori/Tests/Http/RestControllerTest.php new file mode 100644 index 0000000..b3aca02 --- /dev/null +++ b/tests/WebFiori/Tests/Http/RestControllerTest.php @@ -0,0 +1,57 @@ +assertEquals('annotated-service', $service->getName()); + } + + public function testAnnotatedServiceDescription() { + $service = new AnnotatedService(); + $this->assertEquals('A service configured via annotations', $service->getDescription()); + } + + public function testNonAnnotatedService() { + $service = new NonAnnotatedService(); + $this->assertEquals('non-annotated', $service->getName()); + $this->assertEquals('A traditional service', $service->getDescription()); + } + + public function testAnnotationWithEmptyName() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('fallback-name'); + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->assertEquals('fallback-name', $service->getName()); + } + + public function testAnnotationWithoutFallback() { + $service = new class extends \WebFiori\Http\WebService { + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->assertEquals('new-service', $service->getName()); + } + + public function testAnnotatedServiceWithManager() { + $manager = new WebServicesManager(); + $service = new AnnotatedService(); + $manager->addService($service); + + $retrievedService = $manager->getServiceByName('annotated-service'); + $this->assertNotNull($retrievedService); + $this->assertEquals('A service configured via annotations', $retrievedService->getDescription()); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php b/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php new file mode 100644 index 0000000..18de397 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php @@ -0,0 +1,16 @@ +sendResponse('Annotated service response'); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php b/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php new file mode 100644 index 0000000..1eec313 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php @@ -0,0 +1,19 @@ +setDescription('A traditional service'); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $this->sendResponse('Non-annotated service response'); + } +} From 0ad4d946c79f30047b2d3d370b067b781efd5b9b Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 18 Dec 2025 17:29:03 +0300 Subject: [PATCH 02/21] feat: Added Support for Annotations --- WebFiori/Http/Annotations/DeleteMapping.php | 8 ++ WebFiori/Http/Annotations/GetMapping.php | 8 ++ WebFiori/Http/Annotations/PostMapping.php | 8 ++ WebFiori/Http/Annotations/PutMapping.php | 8 ++ WebFiori/Http/Annotations/RequestParam.php | 15 +++ WebFiori/Http/WebService.php | 67 ++++++++++ examples/FullAnnotatedController.php | 115 ++++++++++++++++++ examples/UserControllerExample.php | 64 ++++++++++ .../Http/FullAnnotationIntegrationTest.php | 83 +++++++++++++ .../WebFiori/Tests/Http/MethodMappingTest.php | 59 +++++++++ .../Tests/Http/ParameterMappingTest.php | 52 ++++++++ .../Http/TestServices/AllMethodsService.php | 33 +++++ .../TestServices/MappedMethodsService.php | 34 ++++++ .../TestServices/ParameterMappedService.php | 43 +++++++ 14 files changed, 597 insertions(+) create mode 100644 WebFiori/Http/Annotations/DeleteMapping.php create mode 100644 WebFiori/Http/Annotations/GetMapping.php create mode 100644 WebFiori/Http/Annotations/PostMapping.php create mode 100644 WebFiori/Http/Annotations/PutMapping.php create mode 100644 WebFiori/Http/Annotations/RequestParam.php create mode 100644 examples/FullAnnotatedController.php create mode 100644 examples/UserControllerExample.php create mode 100644 tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php create mode 100644 tests/WebFiori/Tests/Http/MethodMappingTest.php create mode 100644 tests/WebFiori/Tests/Http/ParameterMappingTest.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/AllMethodsService.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/MappedMethodsService.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/ParameterMappedService.php diff --git a/WebFiori/Http/Annotations/DeleteMapping.php b/WebFiori/Http/Annotations/DeleteMapping.php new file mode 100644 index 0000000..7f65726 --- /dev/null +++ b/WebFiori/Http/Annotations/DeleteMapping.php @@ -0,0 +1,8 @@ +setDescription($description); } + + $this->configureMethodMappings(); + } + + /** + * Configure HTTP methods from method annotations. + */ + private function configureMethodMappings(): void { + $reflection = new \ReflectionClass($this); + $methods = []; + + foreach ($reflection->getMethods() as $method) { + $methodMappings = [ + \WebFiori\Http\Annotations\GetMapping::class => RequestMethod::GET, + \WebFiori\Http\Annotations\PostMapping::class => RequestMethod::POST, + \WebFiori\Http\Annotations\PutMapping::class => RequestMethod::PUT, + \WebFiori\Http\Annotations\DeleteMapping::class => RequestMethod::DELETE + ]; + + foreach ($methodMappings as $annotationClass => $httpMethod) { + $attributes = $method->getAttributes($annotationClass); + if (!empty($attributes)) { + $methods[] = $httpMethod; + $this->configureParametersFromMethod($method); + } + } + } + + if (!empty($methods)) { + $this->setRequestMethods(array_unique($methods)); + } + } + + /** + * Configure parameters from method RequestParam annotations. + */ + private function configureParametersFromMethod(\ReflectionMethod $method): void { + $paramAttributes = $method->getAttributes(\WebFiori\Http\Annotations\RequestParam::class); + + foreach ($paramAttributes as $attribute) { + $param = $attribute->newInstance(); + + $this->addParameters([ + $param->name => [ + \WebFiori\Http\ParamOption::TYPE => $this->mapParamType($param->type), + \WebFiori\Http\ParamOption::OPTIONAL => $param->optional, + \WebFiori\Http\ParamOption::DEFAULT => $param->default, + \WebFiori\Http\ParamOption::DESCRIPTION => $param->description + ] + ]); + } + } + + /** + * Map string type to ParamType constant. + */ + private function mapParamType(string $type): string { + return match(strtolower($type)) { + 'int', 'integer' => \WebFiori\Http\ParamType::INT, + 'float', 'double' => \WebFiori\Http\ParamType::DOUBLE, + 'bool', 'boolean' => \WebFiori\Http\ParamType::BOOL, + 'email' => \WebFiori\Http\ParamType::EMAIL, + 'url' => \WebFiori\Http\ParamType::URL, + 'array' => \WebFiori\Http\ParamType::ARR, + 'json' => \WebFiori\Http\ParamType::JSON_OBJ, + default => \WebFiori\Http\ParamType::STRING + }; } /** * Returns an array that contains all possible requests methods at which the * service can be called with. diff --git a/examples/FullAnnotatedController.php b/examples/FullAnnotatedController.php new file mode 100644 index 0000000..c63cc22 --- /dev/null +++ b/examples/FullAnnotatedController.php @@ -0,0 +1,115 @@ +getParamVal('page'); + $limit = $this->getParamVal('limit'); + + $this->sendResponse('Products retrieved', 'success', 200, [ + 'page' => $page, + 'limit' => $limit, + 'products' => [] + ]); + } + + #[PostMapping] + #[RequestParam('name', 'string', false, null, 'Product name')] + #[RequestParam('price', 'float', false, null, 'Product price')] + #[RequestParam('category', 'string', true, 'General', 'Product category')] + public function createProduct() { + $name = $this->getParamVal('name'); + $price = $this->getParamVal('price'); + $category = $this->getParamVal('category'); + + $this->sendResponse('Product created', 'success', 201, [ + 'id' => 123, + 'name' => $name, + 'price' => $price, + 'category' => $category + ]); + } + + #[PutMapping] + #[RequestParam('id', 'int', false, null, 'Product ID')] + #[RequestParam('name', 'string', true)] + #[RequestParam('price', 'float', true)] + public function updateProduct() { + $id = $this->getParamVal('id'); + $name = $this->getParamVal('name'); + $price = $this->getParamVal('price'); + + $this->sendResponse('Product updated', 'success', 200, [ + 'id' => $id, + 'name' => $name, + 'price' => $price + ]); + } + + #[DeleteMapping] + #[RequestParam('id', 'int', false, null, 'Product ID to delete')] + public function deleteProduct() { + $id = $this->getParamVal('id'); + $this->sendResponse('Product deleted', 'success', 200, ['id' => $id]); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + + switch ($method) { + case \WebFiori\Http\RequestMethod::GET: + $this->getProducts(); + break; + case \WebFiori\Http\RequestMethod::POST: + $this->createProduct(); + break; + case \WebFiori\Http\RequestMethod::PUT: + $this->updateProduct(); + break; + case \WebFiori\Http\RequestMethod::DELETE: + $this->deleteProduct(); + break; + } + } +} + +// Demo usage +echo "=== Product Controller Demo ===\n"; + +$controller = new ProductController(); +echo "Service Name: " . $controller->getName() . "\n"; +echo "Description: " . $controller->getDescription() . "\n"; +echo "HTTP Methods: " . implode(', ', $controller->getRequestMethods()) . "\n"; + +echo "\nParameters:\n"; +foreach ($controller->getParameters() as $param) { + echo "- {$param->getName()}: {$param->getType()}" . + ($param->isOptional() ? ' (optional)' : ' (required)') . + ($param->getDescription() ? " - {$param->getDescription()}" : '') . "\n"; +} + +// Integration with WebServicesManager +echo "\n=== Manager Integration ===\n"; +$manager = new WebServicesManager(); +$manager->addService($controller); + +echo "Service registered successfully!\n"; +echo "Available service: " . $manager->getServiceByName('products')->getName() . "\n"; diff --git a/examples/UserControllerExample.php b/examples/UserControllerExample.php new file mode 100644 index 0000000..b95dc47 --- /dev/null +++ b/examples/UserControllerExample.php @@ -0,0 +1,64 @@ +sendResponse('Retrieved all users', 'success', 200, ['users' => []]); + } + + #[PostMapping] + public function createUser() { + $this->sendResponse('User created', 'success', 201, ['id' => 123]); + } + + #[PutMapping] + public function updateUser() { + $this->sendResponse('User updated', 'success', 200); + } + + #[DeleteMapping] + public function deleteUser() { + $this->sendResponse('User deleted', 'success', 200); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + + switch ($method) { + case \WebFiori\Http\RequestMethod::GET: + $this->getUsers(); + break; + case \WebFiori\Http\RequestMethod::POST: + $this->createUser(); + break; + case \WebFiori\Http\RequestMethod::PUT: + $this->updateUser(); + break; + case \WebFiori\Http\RequestMethod::DELETE: + $this->deleteUser(); + break; + default: + $this->sendResponse('Method not allowed', 'error', 405); + } + } +} + +// Usage example +$service = new UserController(); +echo "Service: " . $service->getName() . "\n"; +echo "Description: " . $service->getDescription() . "\n"; +echo "Supported methods: " . implode(', ', $service->getRequestMethods()) . "\n"; diff --git a/tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php b/tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php new file mode 100644 index 0000000..471f1fc --- /dev/null +++ b/tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php @@ -0,0 +1,83 @@ +setName('manual-service'); + $service->addRequestMethod(\WebFiori\Http\RequestMethod::GET); + $service->addParameters([ + 'test' => [ + \WebFiori\Http\ParamOption::TYPE => ParamType::STRING + ] + ]); + + $this->assertEquals('manual-service', $service->getName()); + $this->assertContains(\WebFiori\Http\RequestMethod::GET, $service->getRequestMethods()); + $this->assertNotNull($service->getParameterByName('test')); + } + + public function testAnnotationOverridesManualConfiguration() { + $service = new #[\WebFiori\Http\Annotations\RestController('annotated-override')] + class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('manual-name'); // This should be overridden + } + + #[\WebFiori\Http\Annotations\GetMapping] + public function getData() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->assertEquals('annotated-override', $service->getName()); + $this->assertContains(\WebFiori\Http\RequestMethod::GET, $service->getRequestMethods()); + } + + public function testMixedConfigurationApproach() { + $service = new #[\WebFiori\Http\Annotations\RestController('mixed-service')] + class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct(); + // Add manual configuration after annotation processing + $this->addRequestMethod(\WebFiori\Http\RequestMethod::PATCH); + $this->addParameters([ + 'manual_param' => [ + \WebFiori\Http\ParamOption::TYPE => ParamType::STRING + ] + ]); + } + + #[\WebFiori\Http\Annotations\PostMapping] + #[\WebFiori\Http\Annotations\RequestParam('annotated_param', 'int')] + public function createData() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->assertEquals('mixed-service', $service->getName()); + + $methods = $service->getRequestMethods(); + $this->assertContains(\WebFiori\Http\RequestMethod::POST, $methods); // From annotation + $this->assertContains(\WebFiori\Http\RequestMethod::PATCH, $methods); // Manual addition + + $this->assertNotNull($service->getParameterByName('annotated_param')); // From annotation + $this->assertNotNull($service->getParameterByName('manual_param')); // Manual addition + } +} diff --git a/tests/WebFiori/Tests/Http/MethodMappingTest.php b/tests/WebFiori/Tests/Http/MethodMappingTest.php new file mode 100644 index 0000000..160bb45 --- /dev/null +++ b/tests/WebFiori/Tests/Http/MethodMappingTest.php @@ -0,0 +1,59 @@ +getRequestMethods(); + + $this->assertContains(RequestMethod::GET, $methods); + $this->assertContains(RequestMethod::POST, $methods); + $this->assertCount(2, $methods); + } + + public function testAllMethodMappings() { + $service = new AllMethodsService(); + $methods = $service->getRequestMethods(); + + $this->assertContains(RequestMethod::GET, $methods); + $this->assertContains(RequestMethod::POST, $methods); + $this->assertContains(RequestMethod::PUT, $methods); + $this->assertContains(RequestMethod::DELETE, $methods); + $this->assertCount(4, $methods); + } + + public function testServiceWithoutMethodAnnotations() { + $service = new class extends \WebFiori\Http\WebService { + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $methods = $service->getRequestMethods(); + $this->assertEmpty($methods); + } + + public function testMixedAnnotationAndManualConfiguration() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('mixed-service'); + $this->addRequestMethod(RequestMethod::PATCH); // Manual addition + } + + #[\WebFiori\Http\Annotations\GetMapping] + public function getData() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $methods = $service->getRequestMethods(); + $this->assertContains(RequestMethod::GET, $methods); // From annotation + $this->assertContains(RequestMethod::PATCH, $methods); // Manual addition + } +} diff --git a/tests/WebFiori/Tests/Http/ParameterMappingTest.php b/tests/WebFiori/Tests/Http/ParameterMappingTest.php new file mode 100644 index 0000000..af37c59 --- /dev/null +++ b/tests/WebFiori/Tests/Http/ParameterMappingTest.php @@ -0,0 +1,52 @@ +getParameters(); + + $this->assertCount(4, $parameters); // id, name, email, age + + // Check 'id' parameter + $idParam = $service->getParameterByName('id'); + $this->assertNotNull($idParam); + $this->assertEquals(ParamType::INT, $idParam->getType()); + $this->assertFalse($idParam->isOptional()); + $this->assertEquals('User ID', $idParam->getDescription()); + + // Check 'name' parameter + $nameParam = $service->getParameterByName('name'); + $this->assertNotNull($nameParam); + $this->assertEquals(ParamType::STRING, $nameParam->getType()); + $this->assertTrue($nameParam->isOptional()); + $this->assertEquals('Anonymous', $nameParam->getDefault()); + + // Check 'email' parameter + $emailParam = $service->getParameterByName('email'); + $this->assertNotNull($emailParam); + $this->assertEquals(ParamType::EMAIL, $emailParam->getType()); + $this->assertFalse($emailParam->isOptional()); + + // Check 'age' parameter + $ageParam = $service->getParameterByName('age'); + $this->assertNotNull($ageParam); + $this->assertEquals(ParamType::INT, $ageParam->getType()); + $this->assertTrue($ageParam->isOptional()); + $this->assertEquals(18, $ageParam->getDefault()); + } + + public function testHttpMethodsFromParameterAnnotations() { + $service = new ParameterMappedService(); + $methods = $service->getRequestMethods(); + + $this->assertContains(\WebFiori\Http\RequestMethod::GET, $methods); + $this->assertContains(\WebFiori\Http\RequestMethod::POST, $methods); + $this->assertCount(2, $methods); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/AllMethodsService.php b/tests/WebFiori/Tests/Http/TestServices/AllMethodsService.php new file mode 100644 index 0000000..ba2d1df --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AllMethodsService.php @@ -0,0 +1,33 @@ +sendResponse('All methods service'); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/MappedMethodsService.php b/tests/WebFiori/Tests/Http/TestServices/MappedMethodsService.php new file mode 100644 index 0000000..d23bf4b --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/MappedMethodsService.php @@ -0,0 +1,34 @@ +sendResponse('GET users'); + } + + #[PostMapping] + public function createUser() { + $this->sendResponse('POST user'); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $method = $this->getManager()->getRequestMethod(); + if ($method === \WebFiori\Http\RequestMethod::GET) { + $this->getUsers(); + } elseif ($method === \WebFiori\Http\RequestMethod::POST) { + $this->createUser(); + } + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ParameterMappedService.php b/tests/WebFiori/Tests/Http/TestServices/ParameterMappedService.php new file mode 100644 index 0000000..2ba39af --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ParameterMappedService.php @@ -0,0 +1,43 @@ +getParamVal('id'); + $name = $this->getParamVal('name'); + $this->sendResponse("User $id: $name"); + } + + #[PostMapping] + #[RequestParam('email', 'email', false)] + #[RequestParam('age', 'int', true, 18)] + public function createUser() { + $email = $this->getParamVal('email'); + $age = $this->getParamVal('age'); + $this->sendResponse("Created user: $email, age: $age"); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + if ($method === \WebFiori\Http\RequestMethod::GET) { + $this->getUser(); + } elseif ($method === \WebFiori\Http\RequestMethod::POST) { + $this->createUser(); + } + } +} From 4241fdf42ecf73c0a9448c66b904376e093b5fc9 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 18 Dec 2025 17:52:15 +0300 Subject: [PATCH 03/21] feat: Annotated Auth --- WebFiori/Http/Annotations/AllowAnonymous.php | 8 + WebFiori/Http/Annotations/PreAuthorize.php | 11 ++ WebFiori/Http/Annotations/RequiresAuth.php | 8 + WebFiori/Http/SecurityContext.php | 50 ++++++ WebFiori/Http/WebService.php | 98 ++++++++++++ examples/AuthenticatedController.php | 143 ++++++++++++++++++ .../Http/AuthenticationAnnotationTest.php | 94 ++++++++++++ .../TestServices/ClassLevelAuthService.php | 19 +++ .../Tests/Http/TestServices/SecureService.php | 80 ++++++++++ 9 files changed, 511 insertions(+) create mode 100644 WebFiori/Http/Annotations/AllowAnonymous.php create mode 100644 WebFiori/Http/Annotations/PreAuthorize.php create mode 100644 WebFiori/Http/Annotations/RequiresAuth.php create mode 100644 WebFiori/Http/SecurityContext.php create mode 100644 examples/AuthenticatedController.php create mode 100644 tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/ClassLevelAuthService.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/SecureService.php diff --git a/WebFiori/Http/Annotations/AllowAnonymous.php b/WebFiori/Http/Annotations/AllowAnonymous.php new file mode 100644 index 0000000..0694f3a --- /dev/null +++ b/WebFiori/Http/Annotations/AllowAnonymous.php @@ -0,0 +1,8 @@ +configureMethodMappings(); + $this->configureAuthentication(); + } + + /** + * Configure authentication from annotations. + */ + private function configureAuthentication(): void { + $reflection = new \ReflectionClass($this); + + // Check class-level authentication + $classAuth = $this->getAuthenticationFromClass($reflection); + + // If class has AllowAnonymous, disable auth requirement + if ($classAuth['allowAnonymous']) { + $this->setIsAuthRequired(false); + } elseif ($classAuth['requiresAuth'] || $classAuth['preAuthorize']) { + $this->setIsAuthRequired(true); + } + } + + /** + * Get authentication configuration from class annotations. + */ + private function getAuthenticationFromClass(\ReflectionClass $reflection): array { + return [ + 'allowAnonymous' => !empty($reflection->getAttributes(\WebFiori\Http\Annotations\AllowAnonymous::class)), + 'requiresAuth' => !empty($reflection->getAttributes(\WebFiori\Http\Annotations\RequiresAuth::class)), + 'preAuthorize' => $reflection->getAttributes(\WebFiori\Http\Annotations\PreAuthorize::class) + ]; + } + + /** + * Check method-level authorization before processing. + */ + public function checkMethodAuthorization(): bool { + $reflection = new \ReflectionClass($this); + $method = $this->getCurrentProcessingMethod(); + + if (!$method) { + return $this->isAuthorized(); + } + + $reflectionMethod = $reflection->getMethod($method); + + // Check AllowAnonymous first + if (!empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\AllowAnonymous::class))) { + return true; + } + + // Check RequiresAuth + if (!empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\RequiresAuth::class))) { + if (!SecurityContext::isAuthenticated()) { + return false; + } + } + + // Check PreAuthorize + $preAuthAttributes = $reflectionMethod->getAttributes(\WebFiori\Http\Annotations\PreAuthorize::class); + if (!empty($preAuthAttributes)) { + $preAuth = $preAuthAttributes[0]->newInstance(); + return $this->evaluateSecurityExpression($preAuth->expression); + } + + return $this->isAuthorized(); + } + + /** + * Evaluate security expression (simplified version). + */ + private function evaluateSecurityExpression(string $expression): bool { + // Handle hasRole('ROLE_NAME') + if (preg_match("/hasRole\('([^']+)'\)/", $expression, $matches)) { + return SecurityContext::hasRole($matches[1]); + } + + // Handle hasAuthority('AUTHORITY_NAME') + if (preg_match("/hasAuthority\('([^']+)'\)/", $expression, $matches)) { + return SecurityContext::hasAuthority($matches[1]); + } + + // Handle isAuthenticated() + if ($expression === 'isAuthenticated()') { + return SecurityContext::isAuthenticated(); + } + + // Handle permitAll() + if ($expression === 'permitAll()') { + return true; + } + + return false; + } + + /** + * Get the current processing method name (to be overridden by subclasses if needed). + */ + protected function getCurrentProcessingMethod(): ?string { + return null; // Default implementation } /** diff --git a/examples/AuthenticatedController.php b/examples/AuthenticatedController.php new file mode 100644 index 0000000..353fd8b --- /dev/null +++ b/examples/AuthenticatedController.php @@ -0,0 +1,143 @@ +sendResponse('This is public information - no authentication required'); + } + + #[GetMapping] + #[RequiresAuth] + public function getProfile() { + $user = SecurityContext::getCurrentUser(); + $this->sendResponse('User profile', 200, 'success', [ + 'user' => $user, + 'roles' => SecurityContext::getRoles(), + 'authorities' => SecurityContext::getAuthorities() + ]); + } + + #[PostMapping] + #[PreAuthorize("hasRole('ADMIN')")] + public function adminOperation() { + $this->sendResponse('Admin operation completed successfully'); + } + + #[PostMapping] + #[PreAuthorize("hasAuthority('USER_MANAGE')")] + public function manageUsers() { + $this->sendResponse('User management operation completed'); + } + + public function isAuthorized(): bool { + // This is the fallback authorization check + // In a real application, you might check JWT tokens, session, etc. + return true; + } + + public function processRequest() { + // Check method-level authorization first + if (!$this->checkMethodAuthorization()) { + $this->sendResponse('Access denied', 403, 'error'); + return; + } + + $action = $_GET['action'] ?? 'public'; + + switch ($action) { + case 'public': + $this->getPublicInfo(); + break; + case 'profile': + $this->getProfile(); + break; + case 'admin': + $this->adminOperation(); + break; + case 'manage': + $this->manageUsers(); + break; + default: + $this->sendResponse('Unknown action', 400, 'error'); + } + } + + protected function getCurrentProcessingMethod(): ?string { + $action = $_GET['action'] ?? 'public'; + return match($action) { + 'public' => 'getPublicInfo', + 'profile' => 'getProfile', + 'admin' => 'adminOperation', + 'manage' => 'manageUsers', + default => null + }; + } +} + +// Demo usage +echo "=== Authentication Demo ===\n"; + +$controller = new AuthenticatedController(); + +// Test 1: Public access (no auth required) +echo "\n1. Testing public access:\n"; +$_GET['action'] = 'public'; +$controller->processRequest(); + +// Test 2: Private access without authentication +echo "\n2. Testing private access without auth:\n"; +$_GET['action'] = 'profile'; +$controller->processRequest(); + +// Test 3: Set up authentication +echo "\n3. Setting up authentication:\n"; +SecurityContext::setCurrentUser(['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com']); +SecurityContext::setRoles(['USER']); +SecurityContext::setAuthorities(['USER_READ']); + +echo "User authenticated: " . (SecurityContext::isAuthenticated() ? 'Yes' : 'No') . "\n"; +echo "Roles: " . implode(', ', SecurityContext::getRoles()) . "\n"; +echo "Authorities: " . implode(', ', SecurityContext::getAuthorities()) . "\n"; + +// Test 4: Private access with authentication +echo "\n4. Testing private access with auth:\n"; +$_GET['action'] = 'profile'; +$controller->processRequest(); + +// Test 5: Admin access without admin role +echo "\n5. Testing admin access without admin role:\n"; +$_GET['action'] = 'admin'; +$controller->processRequest(); + +// Test 6: Grant admin role and try again +echo "\n6. Granting admin role and testing admin access:\n"; +SecurityContext::setRoles(['USER', 'ADMIN']); +$controller->processRequest(); + +// Test 7: Authority-based access +echo "\n7. Testing authority-based access:\n"; +$_GET['action'] = 'manage'; +$controller->processRequest(); + +// Test 8: Grant required authority +echo "\n8. Granting USER_MANAGE authority:\n"; +SecurityContext::setAuthorities(['USER_READ', 'USER_MANAGE']); +$controller->processRequest(); + +// Cleanup +SecurityContext::clear(); +unset($_GET['action']); diff --git a/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php b/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php new file mode 100644 index 0000000..ded4c12 --- /dev/null +++ b/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php @@ -0,0 +1,94 @@ +assertFalse($service->isAuthRequired()); + } + + public function testSecurityContextAuthentication() { + // Test unauthenticated state + $this->assertFalse(SecurityContext::isAuthenticated()); + + // Set user and roles + SecurityContext::setCurrentUser(['id' => 1, 'name' => 'John']); + SecurityContext::setRoles(['ADMIN', 'USER']); + SecurityContext::setAuthorities(['USER_CREATE', 'USER_READ']); + + $this->assertTrue(SecurityContext::isAuthenticated()); + $this->assertTrue(SecurityContext::hasRole('ADMIN')); + $this->assertTrue(SecurityContext::hasAuthority('USER_CREATE')); + $this->assertFalse(SecurityContext::hasRole('GUEST')); + } + + public function testMethodLevelAuthorization() { + $service = new SecureService(); + + // Test public method (AllowAnonymous) + $_GET['action'] = 'public'; + $this->assertTrue($service->checkMethodAuthorization()); + + // Test private method without auth (RequiresAuth) + $_GET['action'] = 'private'; + $this->assertFalse($service->checkMethodAuthorization()); + + // Test private method with auth + SecurityContext::setCurrentUser(['id' => 1, 'name' => 'John']); + $this->assertTrue($service->checkMethodAuthorization()); + + // Test admin method without admin role + $_GET['action'] = 'admin'; + SecurityContext::setRoles(['USER']); + $this->assertFalse($service->checkMethodAuthorization()); + + // Test admin method with admin role + SecurityContext::setRoles(['ADMIN']); + $this->assertTrue($service->checkMethodAuthorization()); + + // Test authority-based method + $_GET['action'] = 'create'; + SecurityContext::setAuthorities(['USER_READ']); + $this->assertFalse($service->checkMethodAuthorization()); + + SecurityContext::setAuthorities(['USER_CREATE']); + $this->assertTrue($service->checkMethodAuthorization()); + } + + public function testSecurityExpressions() { + $service = new SecureService(); + $reflection = new \ReflectionClass($service); + $method = $reflection->getMethod('evaluateSecurityExpression'); + $method->setAccessible(true); + + // Test without authentication + $this->assertFalse($method->invoke($service, "hasRole('ADMIN')")); + $this->assertFalse($method->invoke($service, 'isAuthenticated()')); + $this->assertTrue($method->invoke($service, 'permitAll()')); + + // Test with authentication and roles + SecurityContext::setCurrentUser(['id' => 1]); + SecurityContext::setRoles(['ADMIN']); + SecurityContext::setAuthorities(['USER_CREATE']); + + $this->assertTrue($method->invoke($service, "hasRole('ADMIN')")); + $this->assertFalse($method->invoke($service, "hasRole('GUEST')")); + $this->assertTrue($method->invoke($service, "hasAuthority('USER_CREATE')")); + $this->assertTrue($method->invoke($service, 'isAuthenticated()')); + } + + protected function tearDown(): void { + SecurityContext::clear(); + unset($_GET['action']); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ClassLevelAuthService.php b/tests/WebFiori/Tests/Http/TestServices/ClassLevelAuthService.php new file mode 100644 index 0000000..2a4ad81 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ClassLevelAuthService.php @@ -0,0 +1,19 @@ +sendResponse('Public service - no auth required'); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/SecureService.php b/tests/WebFiori/Tests/Http/TestServices/SecureService.php new file mode 100644 index 0000000..5e98b18 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/SecureService.php @@ -0,0 +1,80 @@ +sendResponse('Public data - no auth required'); + } + + #[GetMapping] + #[RequiresAuth] + public function getPrivateData() { + $user = SecurityContext::getCurrentUser(); + $this->sendResponse('Private data for: ' . ($user['name'] ?? 'unknown')); + } + + #[PostMapping] + #[PreAuthorize("hasRole('ADMIN')")] + public function adminOnly() { + $this->sendResponse('Admin-only operation'); + } + + #[PostMapping] + #[PreAuthorize("hasAuthority('USER_CREATE')")] + public function createUser() { + $this->sendResponse('User created'); + } + + public function isAuthorized(): bool { + return true; // Default fallback + } + + public function processRequest() { + if (!$this->checkMethodAuthorization()) { + $this->sendResponse('Unauthorized', 'error', 401); + return; + } + + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + $action = $_GET['action'] ?? 'public'; + + switch ($action) { + case 'public': + $this->getPublicData(); + break; + case 'private': + $this->getPrivateData(); + break; + case 'admin': + $this->adminOnly(); + break; + case 'create': + $this->createUser(); + break; + } + } + + protected function getCurrentProcessingMethod(): ?string { + $action = $_GET['action'] ?? 'public'; + return match($action) { + 'public' => 'getPublicData', + 'private' => 'getPrivateData', + 'admin' => 'adminOnly', + 'create' => 'createUser', + default => null + }; + } +} From 71c38ecaeb739deeae40e7d16d69f5ae277a7553 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Wed, 24 Dec 2025 00:29:07 +0300 Subject: [PATCH 04/21] feat: Added Exceptions --- .../Http/Exceptions/BadRequestException.php | 14 +++++++++ .../Http/Exceptions/ForbiddenException.php | 14 +++++++++ WebFiori/Http/Exceptions/HttpException.php | 29 +++++++++++++++++++ .../Http/Exceptions/NotFoundException.php | 14 +++++++++ .../Http/Exceptions/UnauthorizedException.php | 14 +++++++++ 5 files changed, 85 insertions(+) create mode 100644 WebFiori/Http/Exceptions/BadRequestException.php create mode 100644 WebFiori/Http/Exceptions/ForbiddenException.php create mode 100644 WebFiori/Http/Exceptions/HttpException.php create mode 100644 WebFiori/Http/Exceptions/NotFoundException.php create mode 100644 WebFiori/Http/Exceptions/UnauthorizedException.php diff --git a/WebFiori/Http/Exceptions/BadRequestException.php b/WebFiori/Http/Exceptions/BadRequestException.php new file mode 100644 index 0000000..1f2386a --- /dev/null +++ b/WebFiori/Http/Exceptions/BadRequestException.php @@ -0,0 +1,14 @@ +statusCode = $statusCode; + $this->responseType = $responseType; + } + + public function getStatusCode(): int { + return $this->statusCode; + } + + public function getResponseType(): string { + return $this->responseType; + } +} diff --git a/WebFiori/Http/Exceptions/NotFoundException.php b/WebFiori/Http/Exceptions/NotFoundException.php new file mode 100644 index 0000000..f236d60 --- /dev/null +++ b/WebFiori/Http/Exceptions/NotFoundException.php @@ -0,0 +1,14 @@ + Date: Wed, 24 Dec 2025 00:30:51 +0300 Subject: [PATCH 05/21] chore: Added Samples --- WebFiori/Http/Annotations/ResponseBody.php | 26 +++ WebFiori/Http/WebService.php | 178 +++++++++++++++++- WebFiori/Http/WebServicesManager.php | 50 ++++- examples/AnnotatedService.php | 5 - examples/AuthTestService.php | 19 ++ examples/AuthenticatedController.php | 81 ++++---- examples/CompleteApiDemo.php | 124 ++++++++++++ ...edController.php => ProductController.php} | 26 +-- ...ntrollerExample.php => UserController.php} | 6 - examples/index.php | 18 ++ examples/loader.php | 2 +- .../Tests/Http/ExceptionHandlingTest.php | 109 +++++++++++ .../WebFiori/Tests/Http/ResponseBodyTest.php | 153 +++++++++++++++ .../TestServices/ExceptionTestService.php | 79 ++++++++ .../TestServices/IntegrationTestService.php | 26 +++ .../Tests/Http/TestServices/LegacyService.php | 24 +++ .../TestServices/MixedResponseService.php | 60 ++++++ .../TestServices/ResponseBodyTestService.php | 87 +++++++++ .../WebServicesManagerIntegrationTest.php | 109 +++++++++++ 19 files changed, 1098 insertions(+), 84 deletions(-) create mode 100644 WebFiori/Http/Annotations/ResponseBody.php create mode 100644 examples/AuthTestService.php create mode 100644 examples/CompleteApiDemo.php rename examples/{FullAnnotatedController.php => ProductController.php} (76%) rename examples/{UserControllerExample.php => UserController.php} (87%) create mode 100644 tests/WebFiori/Tests/Http/ExceptionHandlingTest.php create mode 100644 tests/WebFiori/Tests/Http/ResponseBodyTest.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/ExceptionTestService.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/IntegrationTestService.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/LegacyService.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/MixedResponseService.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php create mode 100644 tests/WebFiori/Tests/Http/WebServicesManagerIntegrationTest.php diff --git a/WebFiori/Http/Annotations/ResponseBody.php b/WebFiori/Http/Annotations/ResponseBody.php new file mode 100644 index 0000000..24ab040 --- /dev/null +++ b/WebFiori/Http/Annotations/ResponseBody.php @@ -0,0 +1,26 @@ +configureAuthentication(); } + /** + * Process the web service request with auto-processing support. + * This method should be called instead of processRequest() for auto-processing. + */ + public function processWithAutoHandling(): void { + $targetMethod = $this->getTargetMethod(); + + if ($targetMethod && $this->hasResponseBodyAnnotation($targetMethod)) { + // Check method-level authorization first + if (!$this->checkMethodAuthorization()) { + $this->sendResponse('Access denied', 403, 'error'); + return; + } + + try { + // Call the target method and process its return value + $result = $this->$targetMethod(); + $this->handleMethodResponse($result, $targetMethod); + } catch (HttpException $e) { + // Handle HTTP exceptions automatically + $this->handleException($e); + } catch (\Exception $e) { + // Handle other exceptions as 500 Internal Server Error + $this->sendResponse($e->getMessage(), 500, 'error'); + } + } else { + // Fall back to traditional processRequest() approach + $this->processRequest(); + } + } + + /** + * Check if a method has the ResponseBody annotation. + * + * @param string $methodName The method name to check + * @return bool True if the method has ResponseBody annotation + */ + public function hasResponseBodyAnnotation(string $methodName): bool { + try { + $reflection = new \ReflectionMethod($this, $methodName); + return !empty($reflection->getAttributes(ResponseBody::class)); + } catch (\ReflectionException $e) { + return false; + } + } + + /** + * Handle HTTP exceptions by converting them to appropriate responses. + * + * @param HttpException $exception The HTTP exception to handle + */ + protected function handleException(HttpException $exception): void { + $this->sendResponse( + $exception->getMessage(), + $exception->getStatusCode(), + $exception->getResponseType() + ); + } + + /** + * Configure parameters dynamically for a specific method. + * + * @param string $methodName The method name to configure parameters for + */ + public function configureParametersForMethod(string $methodName): void { + try { + $reflection = new \ReflectionMethod($this, $methodName); + $this->configureParametersFromMethod($reflection); + } catch (\ReflectionException $e) { + // Method doesn't exist, ignore + } + } + /** * Configure authentication from annotations. */ @@ -200,7 +279,7 @@ private function getAuthenticationFromClass(\ReflectionClass $reflection): array */ public function checkMethodAuthorization(): bool { $reflection = new \ReflectionClass($this); - $method = $this->getCurrentProcessingMethod(); + $method = $this->getCurrentProcessingMethod() ?: $this->getTargetMethod(); if (!$method) { return $this->isAuthorized(); @@ -264,6 +343,95 @@ protected function getCurrentProcessingMethod(): ?string { return null; // Default implementation } + /** + * Get the target method name based on current HTTP request. + * + * @return string|null The method name that should handle this request, or null if none found + */ + public function getTargetMethod(): ?string { + $httpMethod = $this->getManager() ? + $this->getManager()->getRequest()->getMethod() : + ($_SERVER['REQUEST_METHOD'] ?? 'GET'); + + // First try to get method from getCurrentProcessingMethod (if implemented) + $currentMethod = $this->getCurrentProcessingMethod(); + if ($currentMethod) { + $reflection = new \ReflectionClass($this); + try { + $method = $reflection->getMethod($currentMethod); + if ($this->methodHandlesHttpMethod($method, $httpMethod)) { + return $currentMethod; + } + } catch (\ReflectionException $e) { + // Method doesn't exist, continue with discovery + } + } + + // Fall back to finding first method that matches HTTP method + $reflection = new \ReflectionClass($this); + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($this->methodHandlesHttpMethod($method, $httpMethod)) { + return $method->getName(); + } + } + + return null; + } + + /** + * Check if a method handles the specified HTTP method. + * + * @param \ReflectionMethod $method The method to check + * @param string $httpMethod The HTTP method (GET, POST, etc.) + * @return bool True if the method handles this HTTP method + */ + private function methodHandlesHttpMethod(\ReflectionMethod $method, string $httpMethod): bool { + $methodMappings = [ + GetMapping::class => RequestMethod::GET, + PostMapping::class => RequestMethod::POST, + PutMapping::class => RequestMethod::PUT, + DeleteMapping::class => RequestMethod::DELETE + ]; + + foreach ($methodMappings as $annotationClass => $mappedMethod) { + if ($httpMethod === $mappedMethod && !empty($method->getAttributes($annotationClass))) { + return true; + } + } + + return false; + } + + /** + * Handle method response by auto-converting return values to HTTP responses. + * + * @param mixed $result The return value from the method + * @param string $methodName The name of the method that was called + * @return void + */ + protected function handleMethodResponse(mixed $result, string $methodName): void { + $reflection = new \ReflectionMethod($this, $methodName); + $responseBodyAttrs = $reflection->getAttributes(ResponseBody::class); + + if (empty($responseBodyAttrs)) { + return; // No auto-processing, method should handle response manually + } + + $responseBody = $responseBodyAttrs[0]->newInstance(); + + // Auto-convert return value to response + if ($result === null) { + // Null return = empty response with configured status + $this->sendResponse('', $responseBody->status, $responseBody->type); + } elseif (is_array($result) || is_object($result)) { + // Array/object = JSON response + $this->sendResponse('Success', $responseBody->status, $responseBody->type, $result); + } else { + // String/scalar = plain response + $this->sendResponse($result, $responseBody->status, $responseBody->type); + } + } + /** * Configure HTTP methods from method annotations. */ @@ -283,7 +451,7 @@ private function configureMethodMappings(): void { $attributes = $method->getAttributes($annotationClass); if (!empty($attributes)) { $methods[] = $httpMethod; - $this->configureParametersFromMethod($method); + // Don't configure parameters here - do it dynamically per request } } } @@ -757,7 +925,7 @@ public function hasParameter(string $name) : bool { * @return bool True if the user is allowed to perform the action. False otherwise. * */ - abstract function isAuthorized() : bool; + public function isAuthorized() : bool {return false;} /** * Returns the value of the property 'requireAuth'. * @@ -800,7 +968,7 @@ public static function isValidName(string $name): bool { /** * Process client's request. */ - abstract function processRequest(); + public function processRequest() {} /** * Removes a request parameter from the service given its name. * diff --git a/WebFiori/Http/WebServicesManager.php b/WebFiori/Http/WebServicesManager.php index 72e670a..f7d98a9 100644 --- a/WebFiori/Http/WebServicesManager.php +++ b/WebFiori/Http/WebServicesManager.php @@ -481,6 +481,13 @@ public final function process() { if ($this->isContentTypeSupported()) { if ($this->_checkAction()) { $actionObj = $this->getServiceByName($this->getCalledServiceName()); + + // Configure parameters for ResponseBody services before getting them + if (method_exists($actionObj, 'processWithAutoHandling') && $this->serviceHasResponseBodyMethods($actionObj)) { + $this->configureServiceParameters($actionObj); + } + + $params = $actionObj->getParameters(); $params = $actionObj->getParameters(); $this->filter->clearParametersDef(); $this->filter->clearInputs(); @@ -1038,6 +1045,11 @@ private function getAction() { } private function isAuth(WebService $service) { if ($service->isAuthRequired()) { + // Check method-level authorization first (handles AllowAnonymous, etc.) + if (method_exists($service, 'checkMethodAuthorization')) { + return $service->checkMethodAuthorization(); + } + $isAuthCheck = 'isAuthorized'.$this->getRequest()->getMethod(); if (!method_exists($service, $isAuthCheck)) { @@ -1050,6 +1062,14 @@ private function isAuth(WebService $service) { return true; } private function processService(WebService $service) { + // Try auto-processing only if service has ResponseBody methods + if (method_exists($service, 'processWithAutoHandling') && $this->serviceHasResponseBodyMethods($service)) { + // Configure parameters for the target method before processing + $this->configureServiceParameters($service); + $service->processWithAutoHandling(); + return; + } + $processMethod = 'process'.$this->getRequest()->getMethod(); if (!method_exists($service, $processMethod)) { @@ -1058,7 +1078,35 @@ private function processService(WebService $service) { $service->$processMethod(); } } - + /** + * Check if service has any methods with ResponseBody annotation. + */ + private function serviceHasResponseBodyMethods(WebService $service): bool { + $reflection = new \ReflectionClass($service); + + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + $attributes = $method->getAttributes(\WebFiori\Http\Annotations\ResponseBody::class); + if (!empty($attributes)) { + return true; + } + } + + return false; + } + + /** + * Configure parameters for the target method of a service. + */ + private function configureServiceParameters(WebService $service): void { + if (method_exists($service, 'getTargetMethod')) { + $targetMethod = $service->getTargetMethod(); + if ($targetMethod && method_exists($service, 'configureParametersForMethod')) { + $reflection = new \ReflectionMethod($service, 'configureParametersForMethod'); + $reflection->setAccessible(true); + $reflection->invoke($service, $targetMethod); + } + } + } private function setOutputStreamHelper($trimmed, $mode) : bool { $tempStream = fopen($trimmed, $mode); diff --git a/examples/AnnotatedService.php b/examples/AnnotatedService.php index e46cdfd..542d83b 100644 --- a/examples/AnnotatedService.php +++ b/examples/AnnotatedService.php @@ -1,5 +1,4 @@ getName() . "\n"; -echo "Service description: " . $service->getDescription() . "\n"; diff --git a/examples/AuthTestService.php b/examples/AuthTestService.php new file mode 100644 index 0000000..4103d18 --- /dev/null +++ b/examples/AuthTestService.php @@ -0,0 +1,19 @@ + 'You have super admin access!']; + } +} diff --git a/examples/AuthenticatedController.php b/examples/AuthenticatedController.php index 353fd8b..93562a9 100644 --- a/examples/AuthenticatedController.php +++ b/examples/AuthenticatedController.php @@ -1,5 +1,4 @@ processRequest(); +// // Test 1: Public access (no auth required) +// echo "\n1. Testing public access:\n"; +// $_GET['action'] = 'public'; +// $controller->processRequest(); -// Test 2: Private access without authentication -echo "\n2. Testing private access without auth:\n"; -$_GET['action'] = 'profile'; -$controller->processRequest(); +// // Test 2: Private access without authentication +// echo "\n2. Testing private access without auth:\n"; +// $_GET['action'] = 'profile'; +// $controller->processRequest(); // Test 3: Set up authentication -echo "\n3. Setting up authentication:\n"; -SecurityContext::setCurrentUser(['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com']); -SecurityContext::setRoles(['USER']); -SecurityContext::setAuthorities(['USER_READ']); +// echo "\n3. Setting up authentication:\n"; +// SecurityContext::setCurrentUser(['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com']); +// SecurityContext::setRoles(['USER']); +// SecurityContext::setAuthorities(['USER_READ']); -echo "User authenticated: " . (SecurityContext::isAuthenticated() ? 'Yes' : 'No') . "\n"; -echo "Roles: " . implode(', ', SecurityContext::getRoles()) . "\n"; -echo "Authorities: " . implode(', ', SecurityContext::getAuthorities()) . "\n"; +// echo "User authenticated: " . (SecurityContext::isAuthenticated() ? 'Yes' : 'No') . "\n"; +// echo "Roles: " . implode(', ', SecurityContext::getRoles()) . "\n"; +// echo "Authorities: " . implode(', ', SecurityContext::getAuthorities()) . "\n"; -// Test 4: Private access with authentication -echo "\n4. Testing private access with auth:\n"; -$_GET['action'] = 'profile'; -$controller->processRequest(); +// // Test 4: Private access with authentication +// echo "\n4. Testing private access with auth:\n"; +// $_GET['action'] = 'profile'; +// $controller->processRequest(); -// Test 5: Admin access without admin role -echo "\n5. Testing admin access without admin role:\n"; -$_GET['action'] = 'admin'; -$controller->processRequest(); +// // Test 5: Admin access without admin role +// echo "\n5. Testing admin access without admin role:\n"; +// $_GET['action'] = 'admin'; +// $controller->processRequest(); -// Test 6: Grant admin role and try again -echo "\n6. Granting admin role and testing admin access:\n"; -SecurityContext::setRoles(['USER', 'ADMIN']); -$controller->processRequest(); +// // Test 6: Grant admin role and try again +// echo "\n6. Granting admin role and testing admin access:\n"; +// SecurityContext::setRoles(['USER', 'ADMIN']); +// $controller->processRequest(); -// Test 7: Authority-based access -echo "\n7. Testing authority-based access:\n"; -$_GET['action'] = 'manage'; -$controller->processRequest(); +// // Test 7: Authority-based access +// echo "\n7. Testing authority-based access:\n"; +// $_GET['action'] = 'manage'; +// $controller->processRequest(); -// Test 8: Grant required authority -echo "\n8. Granting USER_MANAGE authority:\n"; -SecurityContext::setAuthorities(['USER_READ', 'USER_MANAGE']); -$controller->processRequest(); +// // Test 8: Grant required authority +// echo "\n8. Granting USER_MANAGE authority:\n"; +// SecurityContext::setAuthorities(['USER_READ', 'USER_MANAGE']); +// $controller->processRequest(); -// Cleanup -SecurityContext::clear(); -unset($_GET['action']); +// // Cleanup +// SecurityContext::clear(); +// unset($_GET['action']); diff --git a/examples/CompleteApiDemo.php b/examples/CompleteApiDemo.php new file mode 100644 index 0000000..16f4e4a --- /dev/null +++ b/examples/CompleteApiDemo.php @@ -0,0 +1,124 @@ +getParamVal('id'); + + if ($id) { + // Get specific user + if ($id <= 0) { + throw new BadRequestException('Invalid user ID'); + } + + if ($id === 404) { + throw new NotFoundException('User not found'); + } + + return [ + 'user' => [ + 'id' => $id, + 'name' => 'John Doe', + 'email' => 'john@example.com' + ] + ]; + } else { + // Get all users + return [ + 'users' => [ + ['id' => 1, 'name' => 'John Doe'], + ['id' => 2, 'name' => 'Jane Smith'] + ], + 'total' => 2 + ]; + } + } + + #[PostMapping] + #[ResponseBody(status: 201)] + #[RequestParam('name', 'string')] + #[RequestParam('email', 'email')] + #[PreAuthorize("hasAuthority('USER_CREATE')")] + public function createUser(): array { + $name = $this->getParamVal('name'); + $email = $this->getParamVal('email'); + + if (empty($name)) { + throw new BadRequestException('Name is required'); + } + + return [ + 'message' => 'User created successfully', + 'user' => [ + 'id' => rand(1000, 9999), + 'name' => $name, + 'email' => $email, + 'created_at' => date('Y-m-d H:i:s') + ] + ]; + } + + #[PutMapping] + #[ResponseBody] + #[RequestParam('id', 'int')] + #[RequestParam('name', 'string', true)] + #[RequestParam('email', 'email', true)] + #[PreAuthorize("hasAuthority('USER_UPDATE')")] + public function updateUser(): array { + $id = $this->getParamVal('id'); + $name = $this->getParamVal('name'); + $email = $this->getParamVal('email'); + + if ($id === 404) { + throw new NotFoundException('User not found'); + } + + $updates = array_filter([ + 'name' => $name, + 'email' => $email + ]); + + return [ + 'message' => 'User updated successfully', + 'user' => [ + 'id' => $id, + 'updates' => $updates, + 'updated_at' => date('Y-m-d H:i:s') + ] + ]; + } + + #[DeleteMapping] + #[ResponseBody(status: 204)] + #[RequestParam('id', 'int')] + #[PreAuthorize("hasRole('ADMIN')")] + public function deleteUser(): null { + $id = $this->getParamVal('id'); + + if ($id === 404) { + throw new NotFoundException('User not found'); + } + + // Simulate deletion + return null; // Auto-converts to 204 No Content + } +} diff --git a/examples/FullAnnotatedController.php b/examples/ProductController.php similarity index 76% rename from examples/FullAnnotatedController.php rename to examples/ProductController.php index c63cc22..760edaf 100644 --- a/examples/FullAnnotatedController.php +++ b/examples/ProductController.php @@ -1,5 +1,4 @@ getName() . "\n"; -echo "Description: " . $controller->getDescription() . "\n"; -echo "HTTP Methods: " . implode(', ', $controller->getRequestMethods()) . "\n"; - -echo "\nParameters:\n"; -foreach ($controller->getParameters() as $param) { - echo "- {$param->getName()}: {$param->getType()}" . - ($param->isOptional() ? ' (optional)' : ' (required)') . - ($param->getDescription() ? " - {$param->getDescription()}" : '') . "\n"; -} - -// Integration with WebServicesManager -echo "\n=== Manager Integration ===\n"; -$manager = new WebServicesManager(); -$manager->addService($controller); - -echo "Service registered successfully!\n"; -echo "Available service: " . $manager->getServiceByName('products')->getName() . "\n"; +} \ No newline at end of file diff --git a/examples/UserControllerExample.php b/examples/UserController.php similarity index 87% rename from examples/UserControllerExample.php rename to examples/UserController.php index b95dc47..391766c 100644 --- a/examples/UserControllerExample.php +++ b/examples/UserController.php @@ -56,9 +56,3 @@ public function processRequest() { } } } - -// Usage example -$service = new UserController(); -echo "Service: " . $service->getName() . "\n"; -echo "Description: " . $service->getDescription() . "\n"; -echo "Supported methods: " . implode(', ', $service->getRequestMethods()) . "\n"; diff --git a/examples/index.php b/examples/index.php index 0bbe98e..537e386 100644 --- a/examples/index.php +++ b/examples/index.php @@ -4,14 +4,32 @@ require 'HelloWorldService.php'; require 'GetRandomService.php'; require 'HelloWithAuthService.php'; +require 'CompleteApiDemo.php'; +require 'ProductController.php'; +require 'AuthenticatedController.php'; +require 'UserController.php'; +require 'AuthTestService.php'; use HelloWorldService; use GetRandomService; use HelloWithAuthService; use WebFiori\Http\WebServicesManager; +use WebFiori\Http\SecurityContext; + +// Set up authentication context +SecurityContext::setCurrentUser(['id' => 1, 'name' => 'Demo User']); +SecurityContext::setRoles(['USER', 'ADMIN']); +SecurityContext::setCurrentUser(['id' => 1, 'name' => 'Demo User']); +SecurityContext::setRoles(['USER', 'ADMIN']); +SecurityContext::setAuthorities(['USER_CREATE', 'USER_UPDATE', 'USER_DELETE']); $manager = new WebServicesManager(); $manager->addService(new HelloWorldService()); $manager->addService(new GetRandomService()); $manager->addService(new HelloWithAuthService()); +$manager->addService(new CompleteApiDemo()); +$manager->addService(new ProductController()); +$manager->addService(new AuthenticatedController()); +$manager->addService(new UserController()); +$manager->addService(new AuthTestService()); $manager->process(); diff --git a/examples/loader.php b/examples/loader.php index 3290d0c..a83718c 100644 --- a/examples/loader.php +++ b/examples/loader.php @@ -4,4 +4,4 @@ ini_set('display_errors', 1); error_reporting(-1); -require_once '../vendor/autoload.php'; +require_once __DIR__ . '/../vendor/autoload.php'; diff --git a/tests/WebFiori/Tests/Http/ExceptionHandlingTest.php b/tests/WebFiori/Tests/Http/ExceptionHandlingTest.php new file mode 100644 index 0000000..7087c63 --- /dev/null +++ b/tests/WebFiori/Tests/Http/ExceptionHandlingTest.php @@ -0,0 +1,109 @@ +assertEquals(404, $notFound->getStatusCode()); + $this->assertEquals('error', $notFound->getResponseType()); + $this->assertEquals('Resource not found', $notFound->getMessage()); + + $badRequest = new BadRequestException('Invalid input'); + $this->assertEquals(400, $badRequest->getStatusCode()); + $this->assertEquals('error', $badRequest->getResponseType()); + + $unauthorized = new UnauthorizedException('Login required'); + $this->assertEquals(401, $unauthorized->getStatusCode()); + $this->assertEquals('error', $unauthorized->getResponseType()); + + $forbidden = new ForbiddenException('Access denied'); + $this->assertEquals(403, $forbidden->getStatusCode()); + $this->assertEquals('error', $forbidden->getResponseType()); + } + + public function testExceptionDefaults() { + $notFound = new NotFoundException(); + $this->assertEquals('Not Found', $notFound->getMessage()); + + $badRequest = new BadRequestException(); + $this->assertEquals('Bad Request', $badRequest->getMessage()); + + $unauthorized = new UnauthorizedException(); + $this->assertEquals('Unauthorized', $unauthorized->getMessage()); + + $forbidden = new ForbiddenException(); + $this->assertEquals('Forbidden', $forbidden->getMessage()); + } + + public function testServiceExceptionHandling() { + $service = new ExceptionTestService(); + + // Test that method has ResponseBody annotation + $this->assertTrue($service->hasResponseBodyAnnotation('getUser')); + + // Test exception throwing with test parameter + $_GET['test_id'] = 404; + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('User not found'); + $service->getUser(); + } + + public function testDifferentExceptionTypes() { + $service = new ExceptionTestService(); + + // Test BadRequestException + $_GET['test_id'] = 400; + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Invalid user ID'); + $service->getUser(); + } + + public function testUnauthorizedException() { + $service = new ExceptionTestService(); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('Authentication required'); + $service->createUser(); + } + + public function testGenericException() { + $service = new ExceptionTestService(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Generic error'); + $service->getError(); + } + + public function testHandleExceptionMethod() { + $service = new ExceptionTestService(); + $exception = new NotFoundException('Test not found'); + + // Test the handleException method directly + $reflection = new \ReflectionClass($service); + $method = $reflection->getMethod('handleException'); + $method->setAccessible(true); + + // The method should not throw an exception + $this->expectNotToPerformAssertions(); + $method->invoke($service, $exception); + } + + protected function tearDown(): void { + unset($_GET['action']); + unset($_GET['test_id']); + unset($_SERVER['REQUEST_METHOD']); + } +} diff --git a/tests/WebFiori/Tests/Http/ResponseBodyTest.php b/tests/WebFiori/Tests/Http/ResponseBodyTest.php new file mode 100644 index 0000000..20a4831 --- /dev/null +++ b/tests/WebFiori/Tests/Http/ResponseBodyTest.php @@ -0,0 +1,153 @@ +getTargetMethod(); + $this->assertEquals('getArrayData', $targetMethod); + + // Test ResponseBody annotation detection + $this->assertTrue($service->hasResponseBodyAnnotation('getArrayData')); + + // Test return value processing + $result = $service->getArrayData(); + $this->assertIsArray($result); + $this->assertArrayHasKey('users', $result); + } + + public function testStringReturnValue() { + $service = new ResponseBodyTestService(); + $_SERVER['REQUEST_METHOD'] = 'GET'; + + // Test specific method + $this->assertTrue($service->hasResponseBodyAnnotation('getStringData')); + + $result = $service->getStringData(); + $this->assertIsString($result); + $this->assertEquals('Resource created successfully', $result); + } + + public function testNullReturnValue() { + $service = new ResponseBodyTestService(); + $_SERVER['REQUEST_METHOD'] = 'POST'; + + // Test method discovery for POST + $targetMethod = $service->getTargetMethod(); + $this->assertEquals('deleteData', $targetMethod); + + $result = $service->deleteData(); + $this->assertNull($result); + } + + public function testObjectReturnValue() { + $service = new ResponseBodyTestService(); + + // Test specific method + $this->assertTrue($service->hasResponseBodyAnnotation('getObjectData')); + + $result = $service->getObjectData(); + $this->assertIsObject($result); + $this->assertObjectHasProperty('message', $result); + } + + public function testMethodWithoutResponseBody() { + $service = new ResponseBodyTestService(); + + // Should not have ResponseBody annotation + $this->assertFalse($service->hasResponseBodyAnnotation('getManualData')); + } + + public function testMethodWithParameters() { + $service = new ResponseBodyTestService(); + + // Test that method has ResponseBody annotation + $this->assertTrue($service->hasResponseBodyAnnotation('createUser')); + + // Test method has POST mapping + $_SERVER['REQUEST_METHOD'] = 'POST'; + $targetMethod = $service->getTargetMethod(); + $this->assertContains($targetMethod, ['deleteData', 'createUser']); // Either POST method + } + + public function testMixedServiceWithAuthentication() { + $service = new MixedResponseService(); + + // Test methods have correct annotations + $this->assertTrue($service->hasResponseBodyAnnotation('getSecureData')); + $this->assertTrue($service->hasResponseBodyAnnotation('getPublicData')); + $this->assertFalse($service->hasResponseBodyAnnotation('traditionalMethod')); + + // Test with authentication + SecurityContext::setCurrentUser(['id' => 1]); + SecurityContext::setRoles(['USER']); + + // The service should be authorized since we set up proper authentication + $this->assertTrue($service->checkMethodAuthorization()); + } + + public function testLegacyServiceCompatibility() { + $service = new LegacyService(); + + // Should find the GET method + $_SERVER['REQUEST_METHOD'] = 'GET'; + $targetMethod = $service->getTargetMethod(); + $this->assertEquals('getData', $targetMethod); + + // Method should not have ResponseBody annotation + $this->assertFalse($service->hasResponseBodyAnnotation('getData')); + } + + public function testProcessWithAutoHandling() { + $service = new ResponseBodyTestService(); + $_SERVER['REQUEST_METHOD'] = 'GET'; + + // Test the auto-processing logic + $targetMethod = $service->getTargetMethod(); + $hasResponseBody = $service->hasResponseBodyAnnotation($targetMethod); + + $this->assertTrue($hasResponseBody); + $this->assertEquals('getArrayData', $targetMethod); + } + + public function testResponseBodyAnnotationConfiguration() { + $service = new ResponseBodyTestService(); + + // Test default ResponseBody annotation + $reflection = new \ReflectionMethod($service, 'getArrayData'); + $attributes = $reflection->getAttributes(\WebFiori\Http\Annotations\ResponseBody::class); + $this->assertNotEmpty($attributes); + + $responseBody = $attributes[0]->newInstance(); + $this->assertEquals(200, $responseBody->status); + $this->assertEquals('success', $responseBody->type); + + // Test custom ResponseBody annotation + $reflection = new \ReflectionMethod($service, 'getStringData'); + $attributes = $reflection->getAttributes(\WebFiori\Http\Annotations\ResponseBody::class); + $responseBody = $attributes[0]->newInstance(); + $this->assertEquals(201, $responseBody->status); + $this->assertEquals('created', $responseBody->type); + } + + protected function tearDown(): void { + SecurityContext::clear(); + unset($_GET['action']); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ExceptionTestService.php b/tests/WebFiori/Tests/Http/TestServices/ExceptionTestService.php new file mode 100644 index 0000000..42ccd87 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ExceptionTestService.php @@ -0,0 +1,79 @@ +getParamVal('id'); + + // For testing, check if id is set via test + if (!$id && isset($_GET['test_id'])) { + $id = (int)$_GET['test_id']; + } + + if ($id === 404) { + throw new NotFoundException('User not found'); + } + + if ($id === 400) { + throw new BadRequestException('Invalid user ID'); + } + + return ['user' => ['id' => $id, 'name' => 'Test User']]; + } + + #[PostMapping] + #[ResponseBody] + public function createUser(): array { + throw new UnauthorizedException('Authentication required'); + } + + #[GetMapping] + #[ResponseBody] + public function getError(): array { + throw new \Exception('Generic error'); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $action = $_GET['action'] ?? 'get'; + switch ($action) { + case 'get': + $this->getUser(); + break; + case 'create': + $this->createUser(); + break; + case 'error': + $this->getError(); + break; + } + } + + protected function getCurrentProcessingMethod(): ?string { + $action = $_GET['action'] ?? 'get'; + return match($action) { + 'get' => 'getUser', + 'create' => 'createUser', + 'error' => 'getError', + default => null + }; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/IntegrationTestService.php b/tests/WebFiori/Tests/Http/TestServices/IntegrationTestService.php new file mode 100644 index 0000000..aa4b8cd --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/IntegrationTestService.php @@ -0,0 +1,26 @@ + 'Auto-processing via WebServicesManager']; + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + // This should not be called when using auto-processing + $this->sendResponse('Manual processing fallback', 200, 'info'); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/LegacyService.php b/tests/WebFiori/Tests/Http/TestServices/LegacyService.php new file mode 100644 index 0000000..9861305 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/LegacyService.php @@ -0,0 +1,24 @@ +sendResponse('Legacy service response', 200, 'success', ['legacy' => true]); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $this->getData(); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/MixedResponseService.php b/tests/WebFiori/Tests/Http/TestServices/MixedResponseService.php new file mode 100644 index 0000000..878fc5e --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/MixedResponseService.php @@ -0,0 +1,60 @@ + 'data', 'user' => 'authenticated']; + } + + // ResponseBody method without authentication + #[GetMapping] + #[ResponseBody] + #[AllowAnonymous] + public function getPublicData(): array { + return ['public' => 'data', 'access' => 'open']; + } + + // Traditional method (no ResponseBody) + #[PostMapping] + #[AllowAnonymous] + public function traditionalMethod(): void { + $this->sendResponse('Traditional method response', 200, 'success', ['method' => 'traditional']); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $action = $_GET['action'] ?? 'traditional'; + if ($action === 'traditional') { + $this->traditionalMethod(); + } else { + $this->sendResponse('Unknown action', 400, 'error'); + } + } + + protected function getCurrentProcessingMethod(): ?string { + $action = $_GET['action'] ?? 'traditional'; + return match($action) { + 'secure' => 'getSecureData', + 'public' => 'getPublicData', + 'traditional' => 'traditionalMethod', + default => null + }; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php b/tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php new file mode 100644 index 0000000..8ec2580 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php @@ -0,0 +1,87 @@ + [['id' => 1, 'name' => 'John'], ['id' => 2, 'name' => 'Jane']]]; + } + + // Test 2: Return string with custom status + #[GetMapping] + #[ResponseBody(status: 201, type: 'created')] + public function getStringData(): string { + return 'Resource created successfully'; + } + + // Test 3: Return null (should be empty response) + #[PostMapping] + #[ResponseBody(status: 204)] + public function deleteData(): null { + // Simulate deletion + return null; + } + + // Test 4: Return object + #[GetMapping] + #[ResponseBody] + public function getObjectData(): object { + return (object)['message' => 'Hello World', 'timestamp' => time()]; + } + + // Test 5: Method without ResponseBody (manual handling) + #[GetMapping] + #[RequestParam('manual', 'string', true, 'false')] + public function getManualData(): void { + $manual = $this->getParamVal('manual'); + $this->sendResponse('Manual response: ' . $manual, 200, 'success'); + } + + // Test 6: Method with parameters and ResponseBody + #[PostMapping] + #[ResponseBody] + #[RequestParam('name', 'string')] + #[RequestParam('age', 'int', true, 25)] + public function createUser(): array { + return [ + 'user' => [ + 'name' => $this->getParamVal('name'), + 'age' => $this->getParamVal('age'), + 'created_at' => date('Y-m-d H:i:s') + ] + ]; + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + // This should not be called for ResponseBody methods + $this->sendResponse('Fallback processRequest called', 200, 'info'); + } + + protected function getCurrentProcessingMethod(): ?string { + $action = $_GET['action'] ?? 'array'; + return match($action) { + 'array' => 'getArrayData', + 'string' => 'getStringData', + 'null' => 'deleteData', + 'object' => 'getObjectData', + 'manual' => 'getManualData', + 'create' => 'createUser', + default => null + }; + } +} diff --git a/tests/WebFiori/Tests/Http/WebServicesManagerIntegrationTest.php b/tests/WebFiori/Tests/Http/WebServicesManagerIntegrationTest.php new file mode 100644 index 0000000..b8e74f5 --- /dev/null +++ b/tests/WebFiori/Tests/Http/WebServicesManagerIntegrationTest.php @@ -0,0 +1,109 @@ +addService($service); + + // Test that service has processWithAutoHandling method + $this->assertTrue(method_exists($service, 'processWithAutoHandling')); + + // Test that service has ResponseBody methods + $this->assertTrue($service->hasResponseBodyAnnotation('getData')); + } + + public function testManagerProcessesAutoHandlingServices() { + $service = new IntegrationTestService(); + + // Test that service has ResponseBody methods + $this->assertTrue($service->hasResponseBodyAnnotation('getData')); + + // Test method return value + $result = $service->getData(); + $this->assertIsArray($result); + $this->assertEquals('Auto-processing via WebServicesManager', $result['message']); + } + + public function testLegacyServiceStillWorks() { + $service = new LegacyService(); + + // Legacy service should not have ResponseBody methods + $this->assertFalse($service->hasResponseBodyAnnotation('getData')); + + // Should have traditional methods + $this->assertTrue(method_exists($service, 'processRequest')); + } + + public function testMixedServiceTypes() { + $manager = new WebServicesManager(); + + // Add both new and legacy services + $manager->addService(new IntegrationTestService()); + $manager->addService(new LegacyService()); + + // Both should be registered + $this->assertNotNull($manager->getServiceByName('integration-test')); + $this->assertNotNull($manager->getServiceByName('legacy-service')); + } + + public function testResponseBodyWithExceptionHandling() { + $service = new ResponseBodyTestService(); + + // Test that service has ResponseBody methods + $_SERVER['REQUEST_METHOD'] = 'GET'; + $targetMethod = $service->getTargetMethod(); + $this->assertTrue($service->hasResponseBodyAnnotation($targetMethod)); + + // Test method return value + $result = $service->getArrayData(); + $this->assertIsArray($result); + $this->assertArrayHasKey('users', $result); + } + + public function testServiceMethodDiscovery() { + $service = new ResponseBodyTestService(); + + // Test GET method discovery + $_SERVER['REQUEST_METHOD'] = 'GET'; + $targetMethod = $service->getTargetMethod(); + $this->assertEquals('getArrayData', $targetMethod); + + // Test POST method discovery + $_SERVER['REQUEST_METHOD'] = 'POST'; + $targetMethod = $service->getTargetMethod(); + $this->assertContains($targetMethod, ['deleteData', 'createUser']); + } + + public function testBackwardCompatibility() { + // Test that all existing functionality still works + $legacyService = new LegacyService(); + $newService = new IntegrationTestService(); + + // Both should have processRequest method + $this->assertTrue(method_exists($legacyService, 'processRequest')); + $this->assertTrue(method_exists($newService, 'processRequest')); + + // Both should have processWithAutoHandling (inherited from WebService) + $this->assertTrue(method_exists($legacyService, 'processWithAutoHandling')); + $this->assertTrue(method_exists($newService, 'processWithAutoHandling')); + } + + protected function tearDown(): void { + unset($_GET['service']); + unset($_SERVER['REQUEST_METHOD']); + } +} From df0e1fabab29a86b38eb0a48c8277350c278d9fc Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Wed, 24 Dec 2025 13:45:18 +0300 Subject: [PATCH 06/21] test: Updated Test Cases --- tests/WebFiori/Tests/Http/RestControllerTest.php | 4 ---- .../WebFiori/Tests/Http/TestServices/AnnotatedService.php | 6 ------ .../Tests/Http/TestServices/NonAnnotatedService.php | 8 -------- 3 files changed, 18 deletions(-) diff --git a/tests/WebFiori/Tests/Http/RestControllerTest.php b/tests/WebFiori/Tests/Http/RestControllerTest.php index b3aca02..2268a30 100644 --- a/tests/WebFiori/Tests/Http/RestControllerTest.php +++ b/tests/WebFiori/Tests/Http/RestControllerTest.php @@ -29,8 +29,6 @@ public function testAnnotationWithEmptyName() { public function __construct() { parent::__construct('fallback-name'); } - public function isAuthorized(): bool { return true; } - public function processRequest() {} }; $this->assertEquals('fallback-name', $service->getName()); @@ -38,8 +36,6 @@ public function processRequest() {} public function testAnnotationWithoutFallback() { $service = new class extends \WebFiori\Http\WebService { - public function isAuthorized(): bool { return true; } - public function processRequest() {} }; $this->assertEquals('new-service', $service->getName()); diff --git a/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php b/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php index 18de397..852a192 100644 --- a/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php +++ b/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php @@ -6,11 +6,5 @@ #[RestController('annotated-service', 'A service configured via annotations')] class AnnotatedService extends WebService { - public function isAuthorized(): bool { - return true; - } - public function processRequest() { - $this->sendResponse('Annotated service response'); - } } diff --git a/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php b/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php index 1eec313..13679bb 100644 --- a/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php +++ b/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php @@ -8,12 +8,4 @@ public function __construct() { parent::__construct('non-annotated'); $this->setDescription('A traditional service'); } - - public function isAuthorized(): bool { - return true; - } - - public function processRequest() { - $this->sendResponse('Non-annotated service response'); - } } From 0fb0fecbc474372b890c01dada7015f3849b57e4 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Wed, 24 Dec 2025 17:53:51 +0300 Subject: [PATCH 07/21] test: Updated Test Cases --- WebFiori/Http/SecurityContext.php | 93 ++++++++++++++ WebFiori/Http/WebService.php | 42 ++----- WebFiori/Http/WebServicesManager.php | 12 +- .../Tests/Http/RestControllerTest.php | 113 +++++++++++++++++- .../Http/TestServices/AnnotatedService.php | 28 ++++- 5 files changed, 243 insertions(+), 45 deletions(-) diff --git a/WebFiori/Http/SecurityContext.php b/WebFiori/Http/SecurityContext.php index 3f55dab..4afc91c 100644 --- a/WebFiori/Http/SecurityContext.php +++ b/WebFiori/Http/SecurityContext.php @@ -1,50 +1,143 @@ 123, 'name' => 'John Doe', 'email' => 'john@example.com'] + */ public static function setCurrentUser(?array $user): void { self::$currentUser = $user; } + /** + * Get the current authenticated user. + * + * @return array|null User data or null if not authenticated + */ public static function getCurrentUser(): ?array { return self::$currentUser; } + /** + * Set user roles. + * + * @param array $roles Array of role names + * Example: ['USER', 'ADMIN', 'MODERATOR'] + */ public static function setRoles(array $roles): void { self::$roles = $roles; } + /** + * Get user roles. + * + * @return array Array of role names + */ public static function getRoles(): array { return self::$roles; } + /** + * Set user authorities/permissions. + * + * @param array $authorities Array of authority names + * Example: ['USER_CREATE', 'USER_UPDATE', 'USER_DELETE', 'REPORT_VIEW'] + */ public static function setAuthorities(array $authorities): void { self::$authorities = $authorities; } + /** + * Get user authorities/permissions. + * + * @return array Array of authority names + */ public static function getAuthorities(): array { return self::$authorities; } + /** + * Check if user has a specific role. + * + * @param string $role Role name to check + * Example: 'ADMIN', 'USER', 'MODERATOR' + * @return bool True if user has the role + */ public static function hasRole(string $role): bool { return in_array($role, self::$roles); } + /** + * Check if user has a specific authority/permission. + * + * @param string $authority Authority name to check + * Example: 'USER_CREATE', 'USER_DELETE', 'REPORT_VIEW' + * @return bool True if user has the authority + */ public static function hasAuthority(string $authority): bool { return in_array($authority, self::$authorities); } + /** + * Check if a user is currently authenticated. + * + * @return bool True if user is authenticated + */ public static function isAuthenticated(): bool { return self::$currentUser !== null; } + /** + * Clear all security context data. + */ public static function clear(): void { self::$currentUser = null; self::$roles = []; self::$authorities = []; } + + /** + * Evaluate security expression. + * + * @param string $expression Security expression to evaluate + * Example: "hasRole('ADMIN')", "hasAuthority('USER_CREATE')", "isAuthenticated()" + * @return bool True if expression evaluates to true + */ + public static function evaluateExpression(string $expression): bool { + $evalResult = false; + // Handle hasRole('ROLE_NAME') + if (preg_match("/hasRole\('([^']+)'\)/", $expression, $matches)) { + $evalResult = self::hasRole($matches[1]); + } + + // Handle hasAuthority('AUTHORITY_NAME') + if (preg_match("/hasAuthority\('([^']+)'\)/", $expression, $matches)) { + $evalResult &= self::hasAuthority($matches[1]); + } + + // Handle isAuthenticated() + if ($expression === 'isAuthenticated()') { + $evalResult &= self::isAuthenticated(); + } + + return $evalResult; + } } diff --git a/WebFiori/Http/WebService.php b/WebFiori/Http/WebService.php index e11c11a..41f1281 100644 --- a/WebFiori/Http/WebService.php +++ b/WebFiori/Http/WebService.php @@ -258,7 +258,7 @@ private function configureAuthentication(): void { // If class has AllowAnonymous, disable auth requirement if ($classAuth['allowAnonymous']) { $this->setIsAuthRequired(false); - } elseif ($classAuth['requiresAuth'] || $classAuth['preAuthorize']) { + } else if ($classAuth['requiresAuth'] || $classAuth['preAuthorize']) { $this->setIsAuthRequired(true); } } @@ -303,39 +303,13 @@ public function checkMethodAuthorization(): bool { $preAuthAttributes = $reflectionMethod->getAttributes(\WebFiori\Http\Annotations\PreAuthorize::class); if (!empty($preAuthAttributes)) { $preAuth = $preAuthAttributes[0]->newInstance(); - return $this->evaluateSecurityExpression($preAuth->expression); + + return SecurityContext::evaluateExpression($preAuth->expression); } return $this->isAuthorized(); } - /** - * Evaluate security expression (simplified version). - */ - private function evaluateSecurityExpression(string $expression): bool { - // Handle hasRole('ROLE_NAME') - if (preg_match("/hasRole\('([^']+)'\)/", $expression, $matches)) { - return SecurityContext::hasRole($matches[1]); - } - - // Handle hasAuthority('AUTHORITY_NAME') - if (preg_match("/hasAuthority\('([^']+)'\)/", $expression, $matches)) { - return SecurityContext::hasAuthority($matches[1]); - } - - // Handle isAuthenticated() - if ($expression === 'isAuthenticated()') { - return SecurityContext::isAuthenticated(); - } - - // Handle permitAll() - if ($expression === 'permitAll()') { - return true; - } - - return false; - } - /** * Get the current processing method name (to be overridden by subclasses if needed). */ @@ -433,7 +407,7 @@ protected function handleMethodResponse(mixed $result, string $methodName): void } /** - * Configure HTTP methods from method annotations. + * Configure allowed HTTP methods from method annotations. */ private function configureMethodMappings(): void { $reflection = new \ReflectionClass($this); @@ -441,10 +415,10 @@ private function configureMethodMappings(): void { foreach ($reflection->getMethods() as $method) { $methodMappings = [ - \WebFiori\Http\Annotations\GetMapping::class => RequestMethod::GET, - \WebFiori\Http\Annotations\PostMapping::class => RequestMethod::POST, - \WebFiori\Http\Annotations\PutMapping::class => RequestMethod::PUT, - \WebFiori\Http\Annotations\DeleteMapping::class => RequestMethod::DELETE + GetMapping::class => RequestMethod::GET, + PostMapping::class => RequestMethod::POST, + PutMapping::class => RequestMethod::PUT, + DeleteMapping::class => RequestMethod::DELETE ]; foreach ($methodMappings as $annotationClass => $httpMethod) { diff --git a/WebFiori/Http/WebServicesManager.php b/WebFiori/Http/WebServicesManager.php index f7d98a9..93485d8 100644 --- a/WebFiori/Http/WebServicesManager.php +++ b/WebFiori/Http/WebServicesManager.php @@ -483,7 +483,7 @@ public final function process() { $actionObj = $this->getServiceByName($this->getCalledServiceName()); // Configure parameters for ResponseBody services before getting them - if (method_exists($actionObj, 'processWithAutoHandling') && $this->serviceHasResponseBodyMethods($actionObj)) { + if ($this->serviceHasResponseBodyMethods($actionObj)) { $this->configureServiceParameters($actionObj); } @@ -1044,12 +1044,14 @@ private function getAction() { return $retVal; } private function isAuth(WebService $service) { + $isAuth = false; + if ($service->isAuthRequired()) { // Check method-level authorization first (handles AllowAnonymous, etc.) - if (method_exists($service, 'checkMethodAuthorization')) { - return $service->checkMethodAuthorization(); + $isAuth = $service->checkMethodAuthorization(); + if ($isAuth) { + return true; } - $isAuthCheck = 'isAuthorized'.$this->getRequest()->getMethod(); if (!method_exists($service, $isAuthCheck)) { @@ -1063,7 +1065,7 @@ private function isAuth(WebService $service) { } private function processService(WebService $service) { // Try auto-processing only if service has ResponseBody methods - if (method_exists($service, 'processWithAutoHandling') && $this->serviceHasResponseBodyMethods($service)) { + if ($this->serviceHasResponseBodyMethods($service)) { // Configure parameters for the target method before processing $this->configureServiceParameters($service); $service->processWithAutoHandling(); diff --git a/tests/WebFiori/Tests/Http/RestControllerTest.php b/tests/WebFiori/Tests/Http/RestControllerTest.php index 2268a30..13e6487 100644 --- a/tests/WebFiori/Tests/Http/RestControllerTest.php +++ b/tests/WebFiori/Tests/Http/RestControllerTest.php @@ -2,20 +2,19 @@ namespace WebFiori\Tests\Http; use PHPUnit\Framework\TestCase; +use WebFiori\Http\APITestCase; +use WebFiori\Http\SecurityContext; use WebFiori\Http\WebServicesManager; use WebFiori\Tests\Http\TestServices\AnnotatedService; use WebFiori\Tests\Http\TestServices\NonAnnotatedService; -class RestControllerTest extends TestCase { +class RestControllerTest extends APITestCase { public function testAnnotatedServiceName() { $service = new AnnotatedService(); $this->assertEquals('annotated-service', $service->getName()); - } - - public function testAnnotatedServiceDescription() { - $service = new AnnotatedService(); $this->assertEquals('A service configured via annotations', $service->getDescription()); + $this->assertEquals(['GET', 'DELETE'], $service->getRequestMethods()); } public function testNonAnnotatedService() { @@ -49,5 +48,109 @@ public function testAnnotatedServiceWithManager() { $retrievedService = $manager->getServiceByName('annotated-service'); $this->assertNotNull($retrievedService); $this->assertEquals('A service configured via annotations', $retrievedService->getDescription()); + + $this->assertEquals('{'.self::NL + . ' "message":"Method Not Allowed.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":405'.self::NL + . '}', $this->postRequest($manager, 'annotated-service')); + + $this->assertEquals('{'.self::NL + . ' "message":"Hi user!",'.self::NL + . ' "type":"success",'.self::NL + . ' "http-code":200'.self::NL + . '}', $this->getRequest($manager, 'annotated-service')); + + $this->assertEquals('{'.self::NL + . ' "message":"Hi Ibrahim!",'.self::NL + . ' "type":"success",'.self::NL + . ' "http-code":200'.self::NL + . '}', $this->getRequest($manager, 'annotated-service', [ + 'name' => 'Ibrahim' + ])); + } + public function testAnnotatedServiceMethodNotAllowed() { + $manager = new WebServicesManager(); + $service = new AnnotatedService(); + $manager->addService($service); + + $this->assertEquals('{'.self::NL + . ' "message":"Method Not Allowed.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":405'.self::NL + . '}', $this->postRequest($manager, 'annotated-service')); + + } + public function testAnnotatedGet() { + $manager = new WebServicesManager(); + $service = new AnnotatedService(); + $manager->addService($service); + + $retrievedService = $manager->getServiceByName('annotated-service'); + $this->assertNotNull($retrievedService); + $this->assertEquals('A service configured via annotations', $retrievedService->getDescription()); + + $this->assertEquals('{'.self::NL + . ' "message":"Hi user!",'.self::NL + . ' "type":"success",'.self::NL + . ' "http-code":200'.self::NL + . '}', $this->getRequest($manager, 'annotated-service')); + + $this->assertEquals('{'.self::NL + . ' "message":"Hi Ibrahim!",'.self::NL + . ' "type":"success",'.self::NL + . ' "http-code":200'.self::NL + . '}', $this->getRequest($manager, 'annotated-service', [ + 'name' => 'Ibrahim' + ])); + } + public function testAnnotatedDelete() { + $manager = new WebServicesManager(); + $service = new AnnotatedService(); + $manager->addService($service); + + $this->assertEquals('{'.self::NL + . ' "message":"The following required parameter(s) where missing from the request body: \'id\'.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":404,'.self::NL + . ' "more-info":{'.self::NL + . ' "missing":['.self::NL + . ' "id"'.self::NL + . ' ]'.self::NL + . ' }'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service')); + + $this->assertEquals('{'.self::NL + . ' "message":"Not Authorized.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":401'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service', [ + 'id' => 1 + ])); + + SecurityContext::setCurrentUser(['id' => 1, 'name' => 'Ibrahim']); + $this->assertEquals('{'.self::NL + . ' "message":"Not Authorized.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":401'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service', [ + 'id' => 1 + ])); + SecurityContext::setRoles(['ADMIN']); + $this->assertEquals('{'.self::NL + . ' "message":"Not Authorized.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":401'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service', [ + 'id' => 1 + ])); + SecurityContext::setAuthorities(['USER_DELETE']); + $this->assertEquals('{'.self::NL + . ' "message":"Delete user with ID: 1",'.self::NL + . ' "type":"success",'.self::NL + . ' "http-code":200'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service', [ + 'id' => 1 + ])); } } diff --git a/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php b/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php index 852a192..098cec6 100644 --- a/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php +++ b/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php @@ -1,10 +1,36 @@ getParamVal('name'); + + if ($name !== null) { + return "Hi ".$name.'!'; + } + return "Hi user!"; + } + #[DeleteMapping] + #[ResponseBody] + #[RequestParam('id', ParamType::INT)] + #[PreAuthorize("isAuthenticated() && hasRole('ADMIN') && hasAuthority('USER_DELETE')")] + public function delete() { + $id = $this->getParamVal('id'); + return "Delete user with ID: ".$id; + } } From 9cd90b870c71ffc4412285b2ed1b24e1d7e47607 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Wed, 24 Dec 2025 18:00:17 +0300 Subject: [PATCH 08/21] feat: Enhanced Security Context --- WebFiori/Http/SecurityContext.php | 126 ++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 6 deletions(-) diff --git a/WebFiori/Http/SecurityContext.php b/WebFiori/Http/SecurityContext.php index 4afc91c..7db0b78 100644 --- a/WebFiori/Http/SecurityContext.php +++ b/WebFiori/Http/SecurityContext.php @@ -118,26 +118,140 @@ public static function clear(): void { * Evaluate security expression. * * @param string $expression Security expression to evaluate - * Example: "hasRole('ADMIN')", "hasAuthority('USER_CREATE')", "isAuthenticated()" + * + * Simple expressions: + * - "hasRole('ADMIN')" - Check single role + * - "hasAuthority('USER_CREATE')" - Check single authority + * - "isAuthenticated()" - Check if user is logged in + * - "permitAll()" - Always allow access + * + * Multiple values: + * - "hasAnyRole('ADMIN', 'MODERATOR')" - Check any of multiple roles + * - "hasAnyAuthority('USER_CREATE', 'USER_UPDATE')" - Check any of multiple authorities + * + * Complex boolean expressions: + * - "hasRole('ADMIN') && hasAuthority('USER_CREATE')" - Both conditions must be true + * - "hasRole('ADMIN') || hasRole('MODERATOR')" - Either condition can be true + * - "isAuthenticated() && hasAnyRole('USER', 'ADMIN')" - Authenticated with any role + * * @return bool True if expression evaluates to true + * @throws \InvalidArgumentException If expression is invalid */ public static function evaluateExpression(string $expression): bool { - $evalResult = false; + $expression = trim($expression); + + if (empty($expression)) { + throw new \InvalidArgumentException('Security expression cannot be empty'); + } + + // Handle complex boolean expressions with && and || + if (strpos($expression, '&&') !== false) { + return self::evaluateAndExpression($expression); + } + + if (strpos($expression, '||') !== false) { + return self::evaluateOrExpression($expression); + } + + // Handle single expressions + return self::evaluateSingleExpression($expression); + } + + /** + * Evaluate AND expression (all conditions must be true). + */ + private static function evaluateAndExpression(string $expression): bool { + $parts = array_map('trim', explode('&&', $expression)); + + foreach ($parts as $part) { + if (!self::evaluateSingleExpression($part)) { + return false; + } + } + + return true; + } + + /** + * Evaluate OR expression (at least one condition must be true). + */ + private static function evaluateOrExpression(string $expression): bool { + $parts = array_map('trim', explode('||', $expression)); + + foreach ($parts as $part) { + if (self::evaluateSingleExpression($part)) { + return true; + } + } + + return false; + } + + /** + * Evaluate single security expression. + */ + private static function evaluateSingleExpression(string $expression): bool { // Handle hasRole('ROLE_NAME') if (preg_match("/hasRole\('([^']+)'\)/", $expression, $matches)) { - $evalResult = self::hasRole($matches[1]); + return self::hasRole($matches[1]); + } + + // Handle hasAnyRole('ROLE1', 'ROLE2', ...) + if (preg_match("/hasAnyRole\(([^)]+)\)/", $expression, $matches)) { + $roles = self::parseArgumentList($matches[1]); + foreach ($roles as $role) { + if (self::hasRole($role)) { + return true; + } + } + return false; } // Handle hasAuthority('AUTHORITY_NAME') if (preg_match("/hasAuthority\('([^']+)'\)/", $expression, $matches)) { - $evalResult &= self::hasAuthority($matches[1]); + return self::hasAuthority($matches[1]); + } + + // Handle hasAnyAuthority('AUTH1', 'AUTH2', ...) + if (preg_match("/hasAnyAuthority\(([^)]+)\)/", $expression, $matches)) { + $authorities = self::parseArgumentList($matches[1]); + foreach ($authorities as $authority) { + if (self::hasAuthority($authority)) { + return true; + } + } + return false; } // Handle isAuthenticated() if ($expression === 'isAuthenticated()') { - $evalResult &= self::isAuthenticated(); + return self::isAuthenticated(); + } + + // Handle permitAll() + if ($expression === 'permitAll()') { + return true; + } + + throw new \InvalidArgumentException("Invalid security expression: '$expression'"); + } + + /** + * Parse comma-separated argument list from function call. + */ + private static function parseArgumentList(string $args): array { + $result = []; + $parts = explode(',', $args); + + foreach ($parts as $part) { + $part = trim($part); + if (preg_match("/^'([^']+)'$/", $part, $matches)) { + $result[] = $matches[1]; + } else { + throw new \InvalidArgumentException("Invalid argument format: '$part'"); + } } - return $evalResult; + return $result; } } From 04610d0f45c51a40a38b56f9767c4dd44573d12e Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Wed, 24 Dec 2025 18:36:03 +0300 Subject: [PATCH 09/21] refactor: Add `TestUser` class --- WebFiori/Http/APITestCase.php | 66 ++++++++++++++----- WebFiori/Http/SecurityContext.php | 36 ++++++---- WebFiori/Http/UserInterface.php | 37 +++++++++++ .../Http/AuthenticationAnnotationTest.php | 30 ++++----- .../WebFiori/Tests/Http/ResponseBodyTest.php | 4 +- .../Tests/Http/RestControllerTest.php | 25 ++++--- tests/WebFiori/Tests/Http/TestUser.php | 37 +++++++++++ 7 files changed, 179 insertions(+), 56 deletions(-) create mode 100644 WebFiori/Http/UserInterface.php create mode 100644 tests/WebFiori/Tests/Http/TestUser.php diff --git a/WebFiori/Http/APITestCase.php b/WebFiori/Http/APITestCase.php index 0515d6e..06cb908 100644 --- a/WebFiori/Http/APITestCase.php +++ b/WebFiori/Http/APITestCase.php @@ -52,6 +52,7 @@ protected function tearDown(): void { $_POST = $this->backupGlobals['POST']; $_FILES = $this->backupGlobals['FILES']; $_SERVER = $this->backupGlobals['SERVER']; + SecurityContext::clear(); parent::tearDown(); } /** @@ -126,10 +127,14 @@ public function addFile(string $fileIdx, string $filePath, bool $reset = false) * @param array $httpHeaders An optional associative array that can be used * to mimic HTTP request headers. The keys of the array are names of headers * and the value of each key represents the value of the header. + * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. * * @return string The method will return the output of the endpoint. */ - public function callEndpoint(WebServicesManager $manager, string $requestMethod, string $apiEndpointName, array $parameters = [], array $httpHeaders = []) : string { + public function callEndpoint(WebServicesManager $manager, string $requestMethod, string $apiEndpointName, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { $method = strtoupper($requestMethod); $serviceName = $this->resolveServiceName($apiEndpointName); @@ -137,6 +142,7 @@ public function callEndpoint(WebServicesManager $manager, string $requestMethod, $manager->setOutputStream(fopen($this->getOutputFile(), 'w')); $manager->setRequest(Request::createFromGlobals()); + SecurityContext::setCurrentUser($user); $manager->process(); $result = $manager->readOutputStream(); @@ -178,7 +184,11 @@ private function resolveServiceName(string $nameOrClass): string { * @param string $method HTTP method * @param string $serviceName Service name * @param array $parameters Request parameters - * @param array $httpHeaders HTTP headers + * @param array $httpHeaders An optional associative array that can be used + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * + * @param UserInterface|null $user Optional user to authenticate the request with. */ private function setupRequest(string $method, string $serviceName, array $parameters, array $httpHeaders) { putenv('REQUEST_METHOD=' . $method); @@ -257,11 +267,15 @@ public function format(string $output) { * to mimic HTTP request headers. The keys of the array are names of headers * and the value of each key represents the value of the header. * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * * @return string The method will return the output that was produced by * the endpoint as string. */ - public function deleteRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = []) : string { - return $this->callEndpoint($manager, RequestMethod::DELETE, $endpoint, $parameters, $httpHeaders); + public function deleteRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::DELETE, $endpoint, $parameters, $httpHeaders, $user); } /** * Sends a GET request to specific endpoint. @@ -276,8 +290,8 @@ public function deleteRequest(WebServicesManager $manager, string $endpoint, arr * @return string The method will return the output that was produced by * the endpoint as string. */ - public function getRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = []) : string { - return $this->callEndpoint($manager, RequestMethod::GET, $endpoint, $parameters, $httpHeaders); + public function getRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::GET, $endpoint, $parameters, $httpHeaders, $user); } /** * Sends a POST request to specific endpoint. @@ -293,11 +307,15 @@ public function getRequest(WebServicesManager $manager, string $endpoint, array * to mimic HTTP request headers. The keys of the array are names of headers * and the value of each key represents the value of the header. * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * * @return string The method will return the output that was produced by * the endpoint as string. */ - public function postRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = []) : string { - return $this->callEndpoint($manager, RequestMethod::POST, $endpoint, $parameters, $httpHeaders); + public function postRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::POST, $endpoint, $parameters, $httpHeaders, $user); } /** * Sends a PUT request to specific endpoint. @@ -313,11 +331,15 @@ public function postRequest(WebServicesManager $manager, string $endpoint, array * to mimic HTTP request headers. The keys of the array are names of headers * and the value of each key represents the value of the header. * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * * @return string The method will return the output that was produced by * the endpoint as string. */ - public function putRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = []) : string { - return $this->callEndpoint($manager, RequestMethod::PUT, $endpoint, $parameters, $httpHeaders); + public function putRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::PUT, $endpoint, $parameters, $httpHeaders, $user); } /** * Sends a PATCH request to specific endpoint. @@ -333,11 +355,15 @@ public function putRequest(WebServicesManager $manager, string $endpoint, array * to mimic HTTP request headers. The keys of the array are names of headers * and the value of each key represents the value of the header. * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * * @return string The method will return the output that was produced by * the endpoint as string. */ - public function patchRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = []) : string { - return $this->callEndpoint($manager, RequestMethod::PATCH, $endpoint, $parameters, $httpHeaders); + public function patchRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::PATCH, $endpoint, $parameters, $httpHeaders, $user); } /** * Sends an OPTIONS request to specific endpoint. @@ -353,11 +379,15 @@ public function patchRequest(WebServicesManager $manager, string $endpoint, arra * to mimic HTTP request headers. The keys of the array are names of headers * and the value of each key represents the value of the header. * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * * @return string The method will return the output that was produced by * the endpoint as string. */ - public function optionsRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = []) : string { - return $this->callEndpoint($manager, RequestMethod::OPTIONS, $endpoint, $parameters, $httpHeaders); + public function optionsRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::OPTIONS, $endpoint, $parameters, $httpHeaders, $user); } /** * Sends a HEAD request to specific endpoint. @@ -373,11 +403,15 @@ public function optionsRequest(WebServicesManager $manager, string $endpoint, ar * to mimic HTTP request headers. The keys of the array are names of headers * and the value of each key represents the value of the header. * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * * @return string The method will return the output that was produced by * the endpoint as string. */ - public function headRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = []) : string { - return $this->callEndpoint($manager, RequestMethod::HEAD, $endpoint, $parameters, $httpHeaders); + public function headRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::HEAD, $endpoint, $parameters, $httpHeaders, $user); } private function extractPathAndName($absPath): array { $DS = DIRECTORY_SEPARATOR; diff --git a/WebFiori/Http/SecurityContext.php b/WebFiori/Http/SecurityContext.php index 7db0b78..3be0d5a 100644 --- a/WebFiori/Http/SecurityContext.php +++ b/WebFiori/Http/SecurityContext.php @@ -6,33 +6,43 @@ * * Provides static methods to manage the current user's authentication status, * roles, and authorities for request-level security checks. + * + * @author Ibrahim */ class SecurityContext { - /** @var array|null Current authenticated user data */ - private static ?array $currentUser = null; + /** @var UserInterface|null Current authenticated user */ + private static ?UserInterface $currentUser = null; - /** @var array User roles (e.g., ['USER', 'ADMIN']) */ + /** @var array User roles (e.g., ['USER', 'ADMIN']) - deprecated, use user object */ private static array $roles = []; - /** @var array User authorities/permissions (e.g., ['USER_CREATE', 'USER_DELETE']) */ + /** @var array User authorities/permissions (e.g., ['USER_CREATE', 'USER_DELETE']) - deprecated, use user object */ private static array $authorities = []; /** * Set the current authenticated user. * - * @param array|null $user User data array or null for unauthenticated - * Example: ['id' => 123, 'name' => 'John Doe', 'email' => 'john@example.com'] + * @param UserInterface|null $user User object or null for unauthenticated */ - public static function setCurrentUser(?array $user): void { + public static function setCurrentUser(?UserInterface $user): void { self::$currentUser = $user; + + // Update legacy arrays for backward compatibility + if ($user) { + self::$roles = $user->getRoles(); + self::$authorities = $user->getAuthorities(); + } else { + self::$roles = []; + self::$authorities = []; + } } /** * Get the current authenticated user. * - * @return array|null User data or null if not authenticated + * @return UserInterface|null User object or null if not authenticated */ - public static function getCurrentUser(): ?array { + public static function getCurrentUser(): ?UserInterface { return self::$currentUser; } @@ -99,10 +109,10 @@ public static function hasAuthority(string $authority): bool { /** * Check if a user is currently authenticated. * - * @return bool True if user is authenticated + * @return bool True if user is authenticated and active */ public static function isAuthenticated(): bool { - return self::$currentUser !== null; + return self::$currentUser !== null && self::$currentUser->isActive(); } /** @@ -138,6 +148,10 @@ public static function clear(): void { * @throws \InvalidArgumentException If expression is invalid */ public static function evaluateExpression(string $expression): bool { + + if (self::getCurrentUser() === null || !self::getCurrentUser()->isActive()) { + return false; + } $expression = trim($expression); if (empty($expression)) { diff --git a/WebFiori/Http/UserInterface.php b/WebFiori/Http/UserInterface.php new file mode 100644 index 0000000..982468c --- /dev/null +++ b/WebFiori/Http/UserInterface.php @@ -0,0 +1,37 @@ +assertFalse(SecurityContext::isAuthenticated()); // Set user and roles - SecurityContext::setCurrentUser(['id' => 1, 'name' => 'John']); - SecurityContext::setRoles(['ADMIN', 'USER']); - SecurityContext::setAuthorities(['USER_CREATE', 'USER_READ']); + SecurityContext::setCurrentUser(new TestUser(1, ['ADMIN', 'USER'], ['USER_CREATE', 'USER_READ'])); $this->assertTrue(SecurityContext::isAuthenticated()); $this->assertTrue(SecurityContext::hasRole('ADMIN')); @@ -44,7 +43,7 @@ public function testMethodLevelAuthorization() { $this->assertFalse($service->checkMethodAuthorization()); // Test private method with auth - SecurityContext::setCurrentUser(['id' => 1, 'name' => 'John']); + SecurityContext::setCurrentUser(new TestUser(1)); $this->assertTrue($service->checkMethodAuthorization()); // Test admin method without admin role @@ -66,25 +65,20 @@ public function testMethodLevelAuthorization() { } public function testSecurityExpressions() { - $service = new SecureService(); - $reflection = new \ReflectionClass($service); - $method = $reflection->getMethod('evaluateSecurityExpression'); - $method->setAccessible(true); + SecurityContext::clear(); // Test without authentication - $this->assertFalse($method->invoke($service, "hasRole('ADMIN')")); - $this->assertFalse($method->invoke($service, 'isAuthenticated()')); - $this->assertTrue($method->invoke($service, 'permitAll()')); + $this->assertFalse(SecurityContext::evaluateExpression("hasRole('ADMIN')")); + $this->assertFalse(SecurityContext::evaluateExpression('isAuthenticated()')); + $this->assertTrue(SecurityContext::evaluateExpression('permitAll()')); // Test with authentication and roles - SecurityContext::setCurrentUser(['id' => 1]); - SecurityContext::setRoles(['ADMIN']); - SecurityContext::setAuthorities(['USER_CREATE']); + SecurityContext::setCurrentUser(new TestUser(1, ['ADMIN'], ['USER_CREATE'])); - $this->assertTrue($method->invoke($service, "hasRole('ADMIN')")); - $this->assertFalse($method->invoke($service, "hasRole('GUEST')")); - $this->assertTrue($method->invoke($service, "hasAuthority('USER_CREATE')")); - $this->assertTrue($method->invoke($service, 'isAuthenticated()')); + $this->assertTrue(SecurityContext::evaluateExpression("hasRole('ADMIN')")); + $this->assertFalse(SecurityContext::evaluateExpression("hasRole('GUEST')")); + $this->assertTrue(SecurityContext::evaluateExpression("hasAuthority('USER_CREATE')")); + $this->assertTrue(SecurityContext::evaluateExpression('isAuthenticated()')); } protected function tearDown(): void { diff --git a/tests/WebFiori/Tests/Http/ResponseBodyTest.php b/tests/WebFiori/Tests/Http/ResponseBodyTest.php index 20a4831..f918d3c 100644 --- a/tests/WebFiori/Tests/Http/ResponseBodyTest.php +++ b/tests/WebFiori/Tests/Http/ResponseBodyTest.php @@ -3,6 +3,7 @@ use PHPUnit\Framework\TestCase; use WebFiori\Http\SecurityContext; +use WebFiori\Tests\Http\TestUser; use WebFiori\Tests\Http\TestServices\ResponseBodyTestService; use WebFiori\Tests\Http\TestServices\MixedResponseService; use WebFiori\Tests\Http\TestServices\LegacyService; @@ -95,8 +96,7 @@ public function testMixedServiceWithAuthentication() { $this->assertFalse($service->hasResponseBodyAnnotation('traditionalMethod')); // Test with authentication - SecurityContext::setCurrentUser(['id' => 1]); - SecurityContext::setRoles(['USER']); + SecurityContext::setCurrentUser(new TestUser(1, ['USER'])); // The service should be authorized since we set up proper authentication $this->assertTrue($service->checkMethodAuthorization()); diff --git a/tests/WebFiori/Tests/Http/RestControllerTest.php b/tests/WebFiori/Tests/Http/RestControllerTest.php index 13e6487..9ffcc7a 100644 --- a/tests/WebFiori/Tests/Http/RestControllerTest.php +++ b/tests/WebFiori/Tests/Http/RestControllerTest.php @@ -108,7 +108,7 @@ public function testAnnotatedDelete() { $manager = new WebServicesManager(); $service = new AnnotatedService(); $manager->addService($service); - + //Missing param $this->assertEquals('{'.self::NL . ' "message":"The following required parameter(s) where missing from the request body: \'id\'.",'.self::NL . ' "type":"error",'.self::NL @@ -119,7 +119,7 @@ public function testAnnotatedDelete() { . ' ]'.self::NL . ' }'.self::NL . '}', $this->deleteRequest($manager, 'annotated-service')); - + //No auth user $this->assertEquals('{'.self::NL . ' "message":"Not Authorized.",'.self::NL . ' "type":"error",'.self::NL @@ -127,30 +127,37 @@ public function testAnnotatedDelete() { . '}', $this->deleteRequest($manager, 'annotated-service', [ 'id' => 1 ])); - - SecurityContext::setCurrentUser(['id' => 1, 'name' => 'Ibrahim']); + //User with no roles $this->assertEquals('{'.self::NL . ' "message":"Not Authorized.",'.self::NL . ' "type":"error",'.self::NL . ' "http-code":401'.self::NL . '}', $this->deleteRequest($manager, 'annotated-service', [ 'id' => 1 - ])); - SecurityContext::setRoles(['ADMIN']); + ], [], new TestUser(1, [''], [''], true))); + //user with no authorites $this->assertEquals('{'.self::NL . ' "message":"Not Authorized.",'.self::NL . ' "type":"error",'.self::NL . ' "http-code":401'.self::NL . '}', $this->deleteRequest($manager, 'annotated-service', [ 'id' => 1 - ])); - SecurityContext::setAuthorities(['USER_DELETE']); + ], [], new TestUser(1, ['ADMIN'], [''], true))); + // Inactive user + $this->assertEquals('{'.self::NL + . ' "message":"Not Authorized.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":401'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service', [ + 'id' => 1 + ], [], new TestUser(1, ['ADMIN'], ['USER_DELETE'], false))); + //valid user $this->assertEquals('{'.self::NL . ' "message":"Delete user with ID: 1",'.self::NL . ' "type":"success",'.self::NL . ' "http-code":200'.self::NL . '}', $this->deleteRequest($manager, 'annotated-service', [ 'id' => 1 - ])); + ], [], new TestUser(1, ['ADMIN'], ['USER_DELETE'], true))); } } diff --git a/tests/WebFiori/Tests/Http/TestUser.php b/tests/WebFiori/Tests/Http/TestUser.php new file mode 100644 index 0000000..51190e4 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestUser.php @@ -0,0 +1,37 @@ +id = $id; + $this->roles = $roles; + $this->authorities = $authorities; + $this->active = $active; + } + + public function getId(): int|string { + return $this->id; + } + + public function getRoles(): array { + return $this->roles; + } + + public function getAuthorities(): array { + return $this->authorities; + } + + public function isActive(): bool { + return $this->active; + } +} From 1479aa4acdf39808ae8dcb66b87a9c8d6e6bcf3a Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 25 Dec 2025 00:28:55 +0300 Subject: [PATCH 10/21] Update AuthenticationAnnotationTest.php --- tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php b/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php index 8afa7ba..9345a3f 100644 --- a/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php +++ b/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php @@ -23,7 +23,7 @@ public function testSecurityContextAuthentication() { $this->assertFalse(SecurityContext::isAuthenticated()); // Set user and roles - SecurityContext::setCurrentUser(new TestUser(1, ['ADMIN', 'USER'], ['USER_CREATE', 'USER_READ'])); + SecurityContext::setCurrentUser(new TestUser(1, ['ADMIN', 'USER'], ['USER_CREATE', 'USER_READ'], true)); $this->assertTrue(SecurityContext::isAuthenticated()); $this->assertTrue(SecurityContext::hasRole('ADMIN')); @@ -43,7 +43,7 @@ public function testMethodLevelAuthorization() { $this->assertFalse($service->checkMethodAuthorization()); // Test private method with auth - SecurityContext::setCurrentUser(new TestUser(1)); + SecurityContext::setCurrentUser(new TestUser(1, [], [], true)); $this->assertTrue($service->checkMethodAuthorization()); // Test admin method without admin role @@ -73,7 +73,7 @@ public function testSecurityExpressions() { $this->assertTrue(SecurityContext::evaluateExpression('permitAll()')); // Test with authentication and roles - SecurityContext::setCurrentUser(new TestUser(1, ['ADMIN'], ['USER_CREATE'])); + SecurityContext::setCurrentUser(new TestUser(1, ['ADMIN'], ['USER_CREATE'], true)); $this->assertTrue(SecurityContext::evaluateExpression("hasRole('ADMIN')")); $this->assertFalse(SecurityContext::evaluateExpression("hasRole('GUEST')")); From ab62a75d4afe1c7222cadb0a5ea46275b2b1a1fb Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 25 Dec 2025 00:29:06 +0300 Subject: [PATCH 11/21] Update FullAnnotationIntegrationTest.php --- tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php b/tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php index 471f1fc..3b85632 100644 --- a/tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php +++ b/tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php @@ -77,7 +77,7 @@ public function processRequest() {} $this->assertContains(\WebFiori\Http\RequestMethod::POST, $methods); // From annotation $this->assertContains(\WebFiori\Http\RequestMethod::PATCH, $methods); // Manual addition - $this->assertNotNull($service->getParameterByName('annotated_param')); // From annotation + $this->assertNotNull($service->getParameterByName('annotated_param', 'POST')); // From annotation $this->assertNotNull($service->getParameterByName('manual_param')); // Manual addition } } From 9edb176ff2e39774dd8f4cbf04a6113a1fe45ab1 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 25 Dec 2025 00:29:24 +0300 Subject: [PATCH 12/21] Update HttpCookieTest.php --- tests/WebFiori/Tests/Http/HttpCookieTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/WebFiori/Tests/Http/HttpCookieTest.php b/tests/WebFiori/Tests/Http/HttpCookieTest.php index 4e8402a..6304a03 100644 --- a/tests/WebFiori/Tests/Http/HttpCookieTest.php +++ b/tests/WebFiori/Tests/Http/HttpCookieTest.php @@ -96,7 +96,8 @@ public function testRemainingTime00() { $cookie->setExpires(1); $this->assertEquals(60, $cookie->getRemainingTime()); sleep(3); - $this->assertEquals(57, $cookie->getRemainingTime()); + $this->assertGreaterThanOrEqual(55, $cookie->getRemainingTime()); + $this->assertLessThanOrEqual(57, $cookie->getRemainingTime()); } /** * @test From f5d478e03c2c0acf50d307761dded4cf20dd617f Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 25 Dec 2025 00:29:50 +0300 Subject: [PATCH 13/21] Update ParameterMappingTest.php --- tests/WebFiori/Tests/Http/ParameterMappingTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/WebFiori/Tests/Http/ParameterMappingTest.php b/tests/WebFiori/Tests/Http/ParameterMappingTest.php index af37c59..860cb01 100644 --- a/tests/WebFiori/Tests/Http/ParameterMappingTest.php +++ b/tests/WebFiori/Tests/Http/ParameterMappingTest.php @@ -9,6 +9,11 @@ class ParameterMappingTest extends TestCase { public function testParametersFromAnnotations() { $service = new ParameterMappedService(); + + // Trigger parameter configuration for both HTTP methods + $service->getParameterByName('id', 'GET'); + $service->getParameterByName('email', 'POST'); + $parameters = $service->getParameters(); $this->assertCount(4, $parameters); // id, name, email, age From 9e37b45e42440088a99b67410fb0bba542c28440 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 25 Dec 2025 00:30:01 +0300 Subject: [PATCH 14/21] Update MulNubmersService.php --- tests/WebFiori/Tests/Http/TestServices/MulNubmersService.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/WebFiori/Tests/Http/TestServices/MulNubmersService.php b/tests/WebFiori/Tests/Http/TestServices/MulNubmersService.php index cf8afaf..0386417 100644 --- a/tests/WebFiori/Tests/Http/TestServices/MulNubmersService.php +++ b/tests/WebFiori/Tests/Http/TestServices/MulNubmersService.php @@ -27,6 +27,7 @@ public function isAuthorizedGET() { if ($this->getParamVal('first-number') < 0) { return false; } + return true; } public function processGet() { From c996ffb79f62297cd66059e1718ba39800fedcb6 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 25 Dec 2025 00:30:41 +0300 Subject: [PATCH 15/21] Update WebService.php --- WebFiori/Http/WebService.php | 73 +++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/WebFiori/Http/WebService.php b/WebFiori/Http/WebService.php index 41f1281..19fcdd9 100644 --- a/WebFiori/Http/WebService.php +++ b/WebFiori/Http/WebService.php @@ -245,6 +245,51 @@ public function configureParametersForMethod(string $methodName): void { // Method doesn't exist, ignore } } + + /** + * Configure parameters for all methods with RequestParam annotations. + */ + private function configureAllAnnotatedParameters(): void { + $reflection = new \ReflectionClass($this); + foreach ($reflection->getMethods() as $method) { + $paramAttributes = $method->getAttributes(\WebFiori\Http\Annotations\RequestParam::class); + if (!empty($paramAttributes)) { + $this->configureParametersFromMethod($method); + } + } + } + + /** + * Configure parameters for methods with specific HTTP method mapping. + * + * @param string $httpMethod HTTP method (GET, POST, PUT, DELETE, etc.) + */ + private function configureParametersForHttpMethod(string $httpMethod): void { + $reflection = new \ReflectionClass($this); + $httpMethod = strtoupper($httpMethod); + + foreach ($reflection->getMethods() as $method) { + // Check if method has HTTP method mapping annotation + $mappingFound = false; + + // Check for specific HTTP method annotations + $annotations = [ + 'GET' => \WebFiori\Http\Annotations\GetMapping::class, + 'POST' => \WebFiori\Http\Annotations\PostMapping::class, + 'PUT' => \WebFiori\Http\Annotations\PutMapping::class, + 'DELETE' => \WebFiori\Http\Annotations\DeleteMapping::class, + 'PATCH' => \WebFiori\Http\Annotations\PatchMapping::class, + ]; + + if (isset($annotations[$httpMethod])) { + $mappingFound = !empty($method->getAttributes($annotations[$httpMethod])); + } + + if ($mappingFound) { + $this->configureParametersFromMethod($method); + } + } + } /** * Configure authentication from annotations. @@ -310,6 +355,24 @@ public function checkMethodAuthorization(): bool { return $this->isAuthorized(); } + /** + * Check if the method has any authorization annotations. + */ + public function hasMethodAuthorizationAnnotations(): bool { + $reflection = new \ReflectionClass($this); + $method = $this->getCurrentProcessingMethod() ?: $this->getTargetMethod(); + + if (!$method) { + return false; + } + + $reflectionMethod = $reflection->getMethod($method); + + return !empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\AllowAnonymous::class)) || + !empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\RequiresAuth::class)) || + !empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\PreAuthorize::class)); + } + /** * Get the current processing method name (to be overridden by subclasses if needed). */ @@ -796,7 +859,15 @@ public function getObject(string $clazz, array $settersMap = []) { * a parameter with the given name was found. null if nothing is found. * */ - public final function getParameterByName(string $paramName) { + public final function getParameterByName(string $paramName, ?string $httpMethod = null) { + // Configure parameters if HTTP method specified + if ($httpMethod !== null) { + $this->configureParametersForHttpMethod($httpMethod); + } else { + // Configure parameters for all methods with annotations + $this->configureAllAnnotatedParameters(); + } + $trimmed = trim($paramName); if (strlen($trimmed) != 0) { From 218e5fd84e69df58db7440825ff81e3c43e06fc5 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 25 Dec 2025 00:31:02 +0300 Subject: [PATCH 16/21] Update WebServicesManager.php --- WebFiori/Http/WebServicesManager.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/WebFiori/Http/WebServicesManager.php b/WebFiori/Http/WebServicesManager.php index 93485d8..1fe6c5f 100644 --- a/WebFiori/Http/WebServicesManager.php +++ b/WebFiori/Http/WebServicesManager.php @@ -1047,11 +1047,13 @@ private function isAuth(WebService $service) { $isAuth = false; if ($service->isAuthRequired()) { - // Check method-level authorization first (handles AllowAnonymous, etc.) - $isAuth = $service->checkMethodAuthorization(); - if ($isAuth) { - return true; + // Check if method has authorization annotations + if ($service->hasMethodAuthorizationAnnotations()) { + // Use annotation-based authorization + return $service->checkMethodAuthorization(); } + + // Fall back to legacy HTTP-method-specific authorization $isAuthCheck = 'isAuthorized'.$this->getRequest()->getMethod(); if (!method_exists($service, $isAuthCheck)) { From 5a2ad486294db0151c1296b743de6364b7fbee97 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 25 Dec 2025 00:31:24 +0300 Subject: [PATCH 17/21] Update SecurityContext.php --- WebFiori/Http/SecurityContext.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/WebFiori/Http/SecurityContext.php b/WebFiori/Http/SecurityContext.php index 3be0d5a..1cb8165 100644 --- a/WebFiori/Http/SecurityContext.php +++ b/WebFiori/Http/SecurityContext.php @@ -148,10 +148,6 @@ public static function clear(): void { * @throws \InvalidArgumentException If expression is invalid */ public static function evaluateExpression(string $expression): bool { - - if (self::getCurrentUser() === null || !self::getCurrentUser()->isActive()) { - return false; - } $expression = trim($expression); if (empty($expression)) { From bd4119013f203ca584fcd6e6602c22fe53bab9c6 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 29 Dec 2025 16:58:36 +0300 Subject: [PATCH 18/21] fix: Rename Variable to Remove Conflect --- WebFiori/Http/APITestCase.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/WebFiori/Http/APITestCase.php b/WebFiori/Http/APITestCase.php index 06cb908..e64ffc6 100644 --- a/WebFiori/Http/APITestCase.php +++ b/WebFiori/Http/APITestCase.php @@ -35,11 +35,11 @@ class APITestCase extends TestCase { * * @var array */ - private $backupGlobals; + private $globalsBackup; protected function setUp(): void { parent::setUp(); - $this->backupGlobals = [ + $this->globalsBackup = [ 'GET' => $_GET, 'POST' => $_POST, 'FILES' => $_FILES, @@ -48,10 +48,10 @@ protected function setUp(): void { } protected function tearDown(): void { - $_GET = $this->backupGlobals['GET']; - $_POST = $this->backupGlobals['POST']; - $_FILES = $this->backupGlobals['FILES']; - $_SERVER = $this->backupGlobals['SERVER']; + $_GET = $this->globalsBackup['GET']; + $_POST = $this->globalsBackup['POST']; + $_FILES = $this->globalsBackup['FILES']; + $_SERVER = $this->globalsBackup['SERVER']; SecurityContext::clear(); parent::tearDown(); } @@ -99,7 +99,7 @@ public function addFile(string $fileIdx, string $filePath, bool $reset = false) $_FILES[$fileIdx]['error'] = []; } $info = $this->extractPathAndName($filePath); - $path = $info['path'].DS.$info['name']; + $path = $info['path'].DIRECTORY_SEPARATOR.$info['name']; $_FILES[$fileIdx]['name'][] = $info['name']; $_FILES[$fileIdx]['type'][] = mime_content_type($path); From 68e54b5f7b9160b843bcf594f9ca6c6f6afd9421 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 29 Dec 2025 17:39:48 +0300 Subject: [PATCH 19/21] fix: Use of Null as Standalone Type --- .../Tests/Http/TestServices/ResponseBodyTestService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php b/tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php index 8ec2580..31053ef 100644 --- a/tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php +++ b/tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php @@ -28,7 +28,7 @@ public function getStringData(): string { // Test 3: Return null (should be empty response) #[PostMapping] #[ResponseBody(status: 204)] - public function deleteData(): null { + public function deleteData(): ?object { // Simulate deletion return null; } From e83112ccc48a3ba2be521bf90119bba2b5d245f4 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 29 Dec 2025 17:52:21 +0300 Subject: [PATCH 20/21] fix: PHPUnit 9 Missing Method --- tests/WebFiori/Tests/Http/ResponseBodyTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WebFiori/Tests/Http/ResponseBodyTest.php b/tests/WebFiori/Tests/Http/ResponseBodyTest.php index f918d3c..53cb961 100644 --- a/tests/WebFiori/Tests/Http/ResponseBodyTest.php +++ b/tests/WebFiori/Tests/Http/ResponseBodyTest.php @@ -65,7 +65,7 @@ public function testObjectReturnValue() { $result = $service->getObjectData(); $this->assertIsObject($result); - $this->assertObjectHasProperty('message', $result); + $this->assertTrue(property_exists($result, 'message', "The object should have the attribute 'message'.")); } public function testMethodWithoutResponseBody() { From 30da6a9862e8a639480a78ad09bb57947a430721 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 29 Dec 2025 18:05:56 +0300 Subject: [PATCH 21/21] feat: Detection of Duplicated Mappings --- .../Exceptions/DuplicateMappingException.php | 14 +++ WebFiori/Http/WebService.php | 25 +++- .../WebFiori/Tests/Http/ResponseBodyTest.php | 2 +- tests/WebFiori/Tests/Http/WebServiceTest.php | 110 ++++++++++++++++++ 4 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 WebFiori/Http/Exceptions/DuplicateMappingException.php diff --git a/WebFiori/Http/Exceptions/DuplicateMappingException.php b/WebFiori/Http/Exceptions/DuplicateMappingException.php new file mode 100644 index 0000000..da2209d --- /dev/null +++ b/WebFiori/Http/Exceptions/DuplicateMappingException.php @@ -0,0 +1,14 @@ +getMethods() as $method) { $methodMappings = [ @@ -487,14 +487,29 @@ private function configureMethodMappings(): void { foreach ($methodMappings as $annotationClass => $httpMethod) { $attributes = $method->getAttributes($annotationClass); if (!empty($attributes)) { - $methods[] = $httpMethod; - // Don't configure parameters here - do it dynamically per request + if (!isset($httpMethodToMethods[$httpMethod])) { + $httpMethodToMethods[$httpMethod] = []; + } + $httpMethodToMethods[$httpMethod][] = $method->getName(); } } } - if (!empty($methods)) { - $this->setRequestMethods(array_unique($methods)); + // Check for duplicates only if getCurrentProcessingMethod is not overridden + $hasCustomRouting = $reflection->getMethod('getCurrentProcessingMethod')->getDeclaringClass()->getName() !== self::class; + + if (!$hasCustomRouting) { + foreach ($httpMethodToMethods as $httpMethod => $methods) { + if (count($methods) > 1) { + throw new Exceptions\DuplicateMappingException( + "HTTP method $httpMethod is mapped to multiple methods: " . implode(', ', $methods) + ); + } + } + } + + if (!empty($httpMethodToMethods)) { + $this->setRequestMethods(array_keys($httpMethodToMethods)); } } diff --git a/tests/WebFiori/Tests/Http/ResponseBodyTest.php b/tests/WebFiori/Tests/Http/ResponseBodyTest.php index 53cb961..23734cd 100644 --- a/tests/WebFiori/Tests/Http/ResponseBodyTest.php +++ b/tests/WebFiori/Tests/Http/ResponseBodyTest.php @@ -65,7 +65,7 @@ public function testObjectReturnValue() { $result = $service->getObjectData(); $this->assertIsObject($result); - $this->assertTrue(property_exists($result, 'message', "The object should have the attribute 'message'.")); + $this->assertTrue(property_exists($result, 'message'), "The object should have the attribute 'message'."); } public function testMethodWithoutResponseBody() { diff --git a/tests/WebFiori/Tests/Http/WebServiceTest.php b/tests/WebFiori/Tests/Http/WebServiceTest.php index 78e4651..535f905 100644 --- a/tests/WebFiori/Tests/Http/WebServiceTest.php +++ b/tests/WebFiori/Tests/Http/WebServiceTest.php @@ -303,4 +303,114 @@ public function testToString01() { $this->assertEquals('{"post":{"responses":{"201":{"description":"User created successfully"}}},' .'"put":{"responses":{"200":{"description":"User updated successfully"}}}}',$action.''); } + /** + * @test + */ + public function testDuplicateGetMappingThrowsException() { + $this->expectException(\WebFiori\Http\Exceptions\DuplicateMappingException::class); + $this->expectExceptionMessage('HTTP method GET is mapped to multiple methods: getUsers, getUsersAgain'); + + new class extends \WebFiori\Http\WebService { + #[\WebFiori\Http\Annotations\GetMapping] + public function getUsers() {} + + #[\WebFiori\Http\Annotations\GetMapping] + public function getUsersAgain() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + } + + /** + * @test + */ + public function testDuplicatePostMappingThrowsException() { + $this->expectException(\WebFiori\Http\Exceptions\DuplicateMappingException::class); + $this->expectExceptionMessage('HTTP method POST is mapped to multiple methods: createUser, addUser'); + + new class extends \WebFiori\Http\WebService { + #[\WebFiori\Http\Annotations\PostMapping] + public function createUser() {} + + #[\WebFiori\Http\Annotations\PostMapping] + public function addUser() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + } + + /** + * @test + */ + public function testDuplicateMixedMappingsThrowsException() { + $this->expectException(\WebFiori\Http\Exceptions\DuplicateMappingException::class); + $this->expectExceptionMessage('HTTP method PUT is mapped to multiple methods: updateUser, modifyUser'); + + new class extends \WebFiori\Http\WebService { + #[\WebFiori\Http\Annotations\GetMapping] + public function getUser() {} + + #[\WebFiori\Http\Annotations\PutMapping] + public function updateUser() {} + + #[\WebFiori\Http\Annotations\PutMapping] + public function modifyUser() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + } + + /** + * @test + */ + public function testMultipleDuplicateGetMappingThrowsException() { + $this->expectException(\WebFiori\Http\Exceptions\DuplicateMappingException::class); + $this->expectExceptionMessage('HTTP method GET is mapped to multiple methods: getUsers, getUsersAgain, fetchUsers'); + + new class extends \WebFiori\Http\WebService { + #[\WebFiori\Http\Annotations\GetMapping] + public function getUsers() {} + + #[\WebFiori\Http\Annotations\GetMapping] + public function getUsersAgain() {} + + #[\WebFiori\Http\Annotations\GetMapping] + public function fetchUsers() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + } + + /** + * @test + */ + public function testNoDuplicateMappingsDoesNotThrowException() { + $service = new class extends \WebFiori\Http\WebService { + #[\WebFiori\Http\Annotations\GetMapping] + public function getUser() {} + + #[\WebFiori\Http\Annotations\PostMapping] + public function createUser() {} + + #[\WebFiori\Http\Annotations\PutMapping] + public function updateUser() {} + + #[\WebFiori\Http\Annotations\DeleteMapping] + public function deleteUser() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $methods = $service->getRequestMethods(); + $this->assertContains('GET', $methods); + $this->assertContains('POST', $methods); + $this->assertContains('PUT', $methods); + $this->assertContains('DELETE', $methods); + $this->assertCount(4, $methods); + } }