diff --git a/WebFiori/Http/APITestCase.php b/WebFiori/Http/APITestCase.php index 0515d6e..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,11 @@ 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(); } /** @@ -98,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); @@ -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/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 @@ +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 @@ +getRoles(); + self::$authorities = $user->getAuthorities(); + } else { + self::$roles = []; + self::$authorities = []; + } + } + + /** + * Get the current authenticated user. + * + * @return UserInterface|null User object or null if not authenticated + */ + public static function getCurrentUser(): ?UserInterface { + 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 and active + */ + public static function isAuthenticated(): bool { + return self::$currentUser !== null && self::$currentUser->isActive(); + } + + /** + * 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 + * + * 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 { + $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)) { + 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)) { + 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()') { + 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 $result; + } +} 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 @@ +setName($name)) { - $this->setName('new-service'); - } + public function __construct(string $name = '') { $this->reqMethods = []; $this->parameters = []; $this->responses = []; @@ -138,8 +141,413 @@ 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); + } + + $this->configureMethodMappings(); + $this->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 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. + */ + 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); + } else if ($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() ?: $this->getTargetMethod(); + + 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 SecurityContext::evaluateExpression($preAuth->expression); + } + + 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). + */ + 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 allowed HTTP methods from method annotations. + */ + private function configureMethodMappings(): void { + $reflection = new \ReflectionClass($this); + $httpMethodToMethods = []; + + foreach ($reflection->getMethods() as $method) { + $methodMappings = [ + GetMapping::class => RequestMethod::GET, + PostMapping::class => RequestMethod::POST, + PutMapping::class => RequestMethod::PUT, + DeleteMapping::class => RequestMethod::DELETE + ]; + + foreach ($methodMappings as $annotationClass => $httpMethod) { + $attributes = $method->getAttributes($annotationClass); + if (!empty($attributes)) { + if (!isset($httpMethodToMethods[$httpMethod])) { + $httpMethodToMethods[$httpMethod] = []; + } + $httpMethodToMethods[$httpMethod][] = $method->getName(); + } + } + } + + // 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)); + } + } + + /** + * 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. * @@ -466,7 +874,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) { @@ -569,7 +985,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'. * @@ -612,7 +1028,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..1fe6c5f 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 ($this->serviceHasResponseBodyMethods($actionObj)) { + $this->configureServiceParameters($actionObj); + } + + $params = $actionObj->getParameters(); $params = $actionObj->getParameters(); $this->filter->clearParametersDef(); $this->filter->clearInputs(); @@ -1037,7 +1044,16 @@ private function getAction() { return $retVal; } private function isAuth(WebService $service) { + $isAuth = false; + if ($service->isAuthRequired()) { + // 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)) { @@ -1050,6 +1066,14 @@ private function isAuth(WebService $service) { return true; } private function processService(WebService $service) { + // Try auto-processing only if service has ResponseBody methods + if ($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 +1082,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 new file mode 100644 index 0000000..542d83b --- /dev/null +++ b/examples/AnnotatedService.php @@ -0,0 +1,22 @@ +setRequestMethods([RequestMethod::GET]); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $this->sendResponse('Hello from annotated service!'); + } +} + 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 new file mode 100644 index 0000000..93562a9 --- /dev/null +++ b/examples/AuthenticatedController.php @@ -0,0 +1,142 @@ +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/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/ProductController.php b/examples/ProductController.php new file mode 100644 index 0000000..760edaf --- /dev/null +++ b/examples/ProductController.php @@ -0,0 +1,91 @@ +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; + } + } +} \ No newline at end of file diff --git a/examples/UserController.php b/examples/UserController.php new file mode 100644 index 0000000..391766c --- /dev/null +++ b/examples/UserController.php @@ -0,0 +1,58 @@ +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); + } + } +} 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/AuthenticationAnnotationTest.php b/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php new file mode 100644 index 0000000..9345a3f --- /dev/null +++ b/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php @@ -0,0 +1,88 @@ +assertFalse($service->isAuthRequired()); + } + + public function testSecurityContextAuthentication() { + // Test unauthenticated state + $this->assertFalse(SecurityContext::isAuthenticated()); + + // Set user and roles + SecurityContext::setCurrentUser(new TestUser(1, ['ADMIN', 'USER'], ['USER_CREATE', 'USER_READ'], true)); + + $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(new TestUser(1, [], [], true)); + $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() { + SecurityContext::clear(); + + // Test without authentication + $this->assertFalse(SecurityContext::evaluateExpression("hasRole('ADMIN')")); + $this->assertFalse(SecurityContext::evaluateExpression('isAuthenticated()')); + $this->assertTrue(SecurityContext::evaluateExpression('permitAll()')); + + // Test with authentication and roles + SecurityContext::setCurrentUser(new TestUser(1, ['ADMIN'], ['USER_CREATE'], true)); + + $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 { + SecurityContext::clear(); + unset($_GET['action']); + } +} 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/FullAnnotationIntegrationTest.php b/tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php new file mode 100644 index 0000000..3b85632 --- /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', 'POST')); // From annotation + $this->assertNotNull($service->getParameterByName('manual_param')); // Manual addition + } +} 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 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..860cb01 --- /dev/null +++ b/tests/WebFiori/Tests/Http/ParameterMappingTest.php @@ -0,0 +1,57 @@ +getParameterByName('id', 'GET'); + $service->getParameterByName('email', 'POST'); + + $parameters = $service->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/ResponseBodyTest.php b/tests/WebFiori/Tests/Http/ResponseBodyTest.php new file mode 100644 index 0000000..23734cd --- /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->assertTrue(property_exists($result, 'message'), "The object should have the attribute 'message'."); + } + + 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(new TestUser(1, ['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/RestControllerTest.php b/tests/WebFiori/Tests/Http/RestControllerTest.php new file mode 100644 index 0000000..9ffcc7a --- /dev/null +++ b/tests/WebFiori/Tests/Http/RestControllerTest.php @@ -0,0 +1,163 @@ +assertEquals('annotated-service', $service->getName()); + $this->assertEquals('A service configured via annotations', $service->getDescription()); + $this->assertEquals(['GET', 'DELETE'], $service->getRequestMethods()); + } + + 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'); + } + }; + + $this->assertEquals('fallback-name', $service->getName()); + } + + public function testAnnotationWithoutFallback() { + $service = new class extends \WebFiori\Http\WebService { + }; + + $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()); + + $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); + //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 + . ' "http-code":404,'.self::NL + . ' "more-info":{'.self::NL + . ' "missing":['.self::NL + . ' "id"'.self::NL + . ' ]'.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 + . ' "http-code":401'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service', [ + 'id' => 1 + ])); + //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 + ], [], 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 + ], [], 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/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/AnnotatedService.php b/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php new file mode 100644 index 0000000..098cec6 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php @@ -0,0 +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; + } +} 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/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/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/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/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() { diff --git a/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php b/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php new file mode 100644 index 0000000..13679bb --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php @@ -0,0 +1,11 @@ +setDescription('A traditional service'); + } +} 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(); + } + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php b/tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php new file mode 100644 index 0000000..31053ef --- /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(): ?object { + // 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/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 + }; + } +} 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; + } +} 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); + } } 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']); + } +}