diff --git a/.gitignore b/.gitignore index 2a0b5bc..66d6865 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ php-cs-fixer.phar /.vscode tests/WebFiori/Tests/Http/output-stream.txt /OpenAPI_files +/examples/01-core/04-file-uploads/uploads diff --git a/LICENSE b/LICENSE index 8eda521..fc8854a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License - Copyright 2019 Ibrahim BinAlshikh, WebFiori HTTP. + Copyright 2019-present, WebFiori Framework. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 89951df..4f49475 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,18 @@ A powerful and flexible PHP library for creating RESTful web APIs with built-in - [Key Features](#key-features) - [Installation](#installation) - [Quick Start](#quick-start) + - [Modern Approach with Attributes](#modern-approach-with-attributes) + - [Traditional Approach](#traditional-approach) - [Core Concepts](#core-concepts) - [Creating Web Services](#creating-web-services) + - [Using Attributes (Recommended)](#using-attributes-recommended) + - [Traditional Class-Based Approach](#traditional-class-based-approach) - [Parameter Management](#parameter-management) - [Authentication & Authorization](#authentication--authorization) - [Request & Response Handling](#request--response-handling) - [Advanced Features](#advanced-features) + - [Object Mapping](#object-mapping-1) + - [OpenAPI Documentation](#openapi-documentation) - [Testing](#testing) - [Examples](#examples) - [API Documentation](#api-documentation) @@ -77,19 +83,53 @@ require_once 'path/to/webfiori-http/vendor/autoload.php'; ## Quick Start -Here's a simple example to get you started: +### Modern Approach with Attributes + +PHP 8+ attributes provide a clean, declarative way to define web services: ```php $message, 'timestamp' => time()]; + } +} +``` +### Traditional Approach + +The traditional approach using constructor configuration: + +```php +getParamVal('name'); - - if ($name !== null) { - $this->sendResponse("Hello, $name!"); - } else { - $this->sendResponse("Hello, World!"); - } + $this->sendResponse($name ? "Hello, $name!" : "Hello, World!"); } } +``` + +Both approaches work with `WebServicesManager`: -// Set up the services manager +```php $manager = new WebServicesManager(); $manager->addService(new HelloService()); $manager->process(); @@ -148,7 +185,60 @@ The library follows a service-oriented architecture: ## Creating Web Services -### Basic Service Structure +### Using Attributes (Recommended) + +PHP 8+ attributes provide a modern, declarative approach: + +```php + $id ?? 1, 'name' => 'John Doe']; + } + + #[PostMapping] + #[ResponseBody] + #[Param('name', ParamType::STRING, 'User name', minLength: 2)] + #[Param('email', ParamType::EMAIL, 'User email')] + public function createUser(string $name, string $email): array { + return ['id' => 2, 'name' => $name, 'email' => $email]; + } + + #[PutMapping] + #[ResponseBody] + #[Param('id', ParamType::INT, 'User ID')] + #[Param('name', ParamType::STRING, 'User name')] + public function updateUser(int $id, string $name): array { + return ['id' => $id, 'name' => $name]; + } + + #[DeleteMapping] + #[ResponseBody] + #[Param('id', ParamType::INT, 'User ID')] + public function deleteUser(int $id): array { + return ['deleted' => $id]; + } +} +``` + +### Traditional Class-Based Approach Every web service must extend `AbstractWebService` and implement the `processRequest()` method: @@ -527,7 +617,7 @@ $customFilter = function($original, $filtered) { // Additional processing return strtoupper($filtered); -}; +]; $this->addParameters([ 'code' => [ @@ -537,6 +627,36 @@ $this->addParameters([ ]); ``` +### OpenAPI Documentation + +Generate OpenAPI 3.1.0 specification for your API: + +```php +$manager = new WebServicesManager(); +$manager->setVersion('1.0.0'); +$manager->setDescription('My REST API'); + +// Add your services +$manager->addService(new UserService()); +$manager->addService(new ProductService()); + +// Generate OpenAPI specification +$openApiObj = $manager->toOpenAPI(); + +// Customize if needed +$info = $openApiObj->getInfo(); +$info->setTermsOfService('https://example.com/terms'); + +// Output as JSON +header('Content-Type: application/json'); +echo $openApiObj->toJSON(); +``` + +The generated specification can be used with: +- Swagger UI for interactive documentation +- Postman for API testing +- Code generators for client SDKs + ## Testing ### Using APITestCase @@ -735,9 +855,6 @@ class FileUploadService extends AbstractWebService { For more examples, check the [examples](examples/) directory in this repository. -## API Documentation - -This library is part of the WebFiori Framework. For complete API documentation, visit: https://webfiori.com/docs/webfiori/http ### Key Classes Documentation diff --git a/WebFiori/Http/APIFilter.php b/WebFiori/Http/APIFilter.php index 19fddfa..0cfcfce 100644 --- a/WebFiori/Http/APIFilter.php +++ b/WebFiori/Http/APIFilter.php @@ -2,7 +2,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/http/blob/master/LICENSE diff --git a/WebFiori/Http/APITestCase.php b/WebFiori/Http/APITestCase.php index e64ffc6..05c565d 100644 --- a/WebFiori/Http/APITestCase.php +++ b/WebFiori/Http/APITestCase.php @@ -2,7 +2,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/http/blob/master/LICENSE @@ -128,13 +128,13 @@ public function addFile(string $fileIdx, string $filePath, bool $reset = false) * 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. + * @param SecurityPrincipal|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 = [], ?UserInterface $user = null) : string { + public function callEndpoint(WebServicesManager $manager, string $requestMethod, string $apiEndpointName, array $parameters = [], array $httpHeaders = [], ?SecurityPrincipal $user = null) : string { $method = strtoupper($requestMethod); $serviceName = $this->resolveServiceName($apiEndpointName); @@ -188,7 +188,7 @@ private function resolveServiceName(string $nameOrClass): string { * 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. + * @param SecurityPrincipal|null $user Optional user to authenticate the request with. */ private function setupRequest(string $method, string $serviceName, array $parameters, array $httpHeaders) { putenv('REQUEST_METHOD=' . $method); @@ -267,14 +267,14 @@ 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. + * @param SecurityPrincipal|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 = [], ?UserInterface $user = null) : string { + public function deleteRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?SecurityPrincipal $user = null) : string { return $this->callEndpoint($manager, RequestMethod::DELETE, $endpoint, $parameters, $httpHeaders, $user); } /** @@ -290,7 +290,7 @@ 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 = [], ?UserInterface $user = null) : string { + public function getRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?SecurityPrincipal $user = null) : string { return $this->callEndpoint($manager, RequestMethod::GET, $endpoint, $parameters, $httpHeaders, $user); } /** @@ -307,14 +307,14 @@ 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. + * @param SecurityPrincipal|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 = [], ?UserInterface $user = null) : string { + public function postRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?SecurityPrincipal $user = null) : string { return $this->callEndpoint($manager, RequestMethod::POST, $endpoint, $parameters, $httpHeaders, $user); } /** @@ -331,14 +331,14 @@ 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. + * @param SecurityPrincipal|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 = [], ?UserInterface $user = null) : string { + public function putRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?SecurityPrincipal $user = null) : string { return $this->callEndpoint($manager, RequestMethod::PUT, $endpoint, $parameters, $httpHeaders, $user); } /** @@ -355,14 +355,14 @@ 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. + * @param SecurityPrincipal|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 = [], ?UserInterface $user = null) : string { + public function patchRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?SecurityPrincipal $user = null) : string { return $this->callEndpoint($manager, RequestMethod::PATCH, $endpoint, $parameters, $httpHeaders, $user); } /** @@ -379,14 +379,14 @@ 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. + * @param SecurityPrincipal|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 = [], ?UserInterface $user = null) : string { + public function optionsRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?SecurityPrincipal $user = null) : string { return $this->callEndpoint($manager, RequestMethod::OPTIONS, $endpoint, $parameters, $httpHeaders, $user); } /** @@ -403,14 +403,14 @@ 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. + * @param SecurityPrincipal|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 = [], ?UserInterface $user = null) : string { + public function headRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?SecurityPrincipal $user = null) : string { return $this->callEndpoint($manager, RequestMethod::HEAD, $endpoint, $parameters, $httpHeaders, $user); } private function extractPathAndName($absPath): array { diff --git a/WebFiori/Http/AbstractWebService.php b/WebFiori/Http/AbstractWebService.php index 516be9a..997fd2f 100644 --- a/WebFiori/Http/AbstractWebService.php +++ b/WebFiori/Http/AbstractWebService.php @@ -1,4 +1,13 @@ getSettersMap() as $method => $paramName) { if (is_callable([$instance, $method])) { - try { - if ($inputs instanceof Json) { - $instance->$method($inputs->get($paramName)); - } else if (gettype($inputs) == 'array') { - $instance->$method($inputs[$paramName]); - } - } catch (Throwable $ex) { + if ($inputs instanceof Json) { + $instance->$method($inputs->get($paramName)); + } else if (gettype($inputs) == 'array') { + $instance->$method($inputs[$paramName]); } } } diff --git a/WebFiori/Http/OpenAPI/APIResponseDefinition.php b/WebFiori/Http/OpenAPI/APIResponseDefinition.php index 6c28712..d50f712 100644 --- a/WebFiori/Http/OpenAPI/APIResponseDefinition.php +++ b/WebFiori/Http/OpenAPI/APIResponseDefinition.php @@ -1,4 +1,13 @@ name = $nameTrimmed; + // Check for reserved parameter names + if (in_array(strtolower($nameTrimmed), self::RESERVED_NAMES)) { + throw new \InvalidArgumentException("Parameter name '$nameTrimmed' is reserved and cannot be used. Reserved names: " . implode(', ', self::RESERVED_NAMES)); + } + return true; } diff --git a/WebFiori/Http/RequestUri.php b/WebFiori/Http/RequestUri.php index cacb70d..cd18310 100644 --- a/WebFiori/Http/RequestUri.php +++ b/WebFiori/Http/RequestUri.php @@ -2,7 +2,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2025 WebFiori Framework + * Copyright (c) 2025-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/http/blob/master/LICENSE diff --git a/WebFiori/Http/Response.php b/WebFiori/Http/Response.php index 0977166..b2701a4 100644 --- a/WebFiori/Http/Response.php +++ b/WebFiori/Http/Response.php @@ -2,7 +2,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/http/blob/master/LICENSE diff --git a/WebFiori/Http/ResponseMessage.php b/WebFiori/Http/ResponseMessage.php index 333e151..ad2b2be 100644 --- a/WebFiori/Http/ResponseMessage.php +++ b/WebFiori/Http/ResponseMessage.php @@ -1,4 +1,13 @@ $targetMethod(); + // Inject parameters into method call + $params = $this->getMethodParameters($targetMethod); + $result = $this->$targetMethod(...$params); $this->handleMethodResponse($result, $targetMethod); } catch (HttpException $e) { // Handle HTTP exceptions automatically @@ -332,23 +333,43 @@ public function checkMethodAuthorization(): bool { $reflectionMethod = $reflection->getMethod($method); + // Check for conflicting annotations + $hasAllowAnonymous = !empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\AllowAnonymous::class)); + $hasRequiresAuth = !empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\RequiresAuth::class)); + + if ($hasAllowAnonymous && $hasRequiresAuth) { + throw new \InvalidArgumentException( + "Method '$method' has conflicting annotations: #[AllowAnonymous] and #[RequiresAuth] cannot be used together" + ); + } + // Check AllowAnonymous first - if (!empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\AllowAnonymous::class))) { + if ($hasAllowAnonymous) { return true; } // Check RequiresAuth - if (!empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\RequiresAuth::class))) { - if (!SecurityContext::isAuthenticated()) { + if ($hasRequiresAuth) { + // First call isAuthorized() + if (!$this->isAuthorized()) { return false; } + + // Then check for PreAuthorize + $preAuthAttributes = $reflectionMethod->getAttributes(\WebFiori\Http\Annotations\PreAuthorize::class); + if (!empty($preAuthAttributes)) { + $preAuth = $preAuthAttributes[0]->newInstance(); + return SecurityContext::evaluateExpression($preAuth->expression); + } + + // If no PreAuthorize, continue based on isAuthorized (already passed) + return true; } - // Check PreAuthorize + // Check PreAuthorize without RequiresAuth $preAuthAttributes = $reflectionMethod->getAttributes(\WebFiori\Http\Annotations\PreAuthorize::class); if (!empty($preAuthAttributes)) { $preAuth = $preAuthAttributes[0]->newInstance(); - return SecurityContext::evaluateExpression($preAuth->expression); } @@ -439,6 +460,41 @@ private function methodHandlesHttpMethod(\ReflectionMethod $method, string $http return false; } + /** + * Get method parameters by extracting values from request. + * + * @param string $methodName The method name + * @return array Array of parameter values in correct order + */ + private function getMethodParameters(string $methodName): array { + $reflection = new \ReflectionMethod($this, $methodName); + $params = []; + + // Check for MapEntity attribute + $mapEntityAttrs = $reflection->getAttributes(\WebFiori\Http\Annotations\MapEntity::class); + + if (!empty($mapEntityAttrs)) { + $mapEntity = $mapEntityAttrs[0]->newInstance(); + $mappedObject = $this->getObject($mapEntity->entityClass, $mapEntity->setters); + $params[] = $mappedObject; + } else { + // Original parameter handling + foreach ($reflection->getParameters() as $param) { + $paramName = $param->getName(); + $value = $this->getParamVal($paramName); + + // Handle optional parameters with defaults + if ($value === null && $param->isDefaultValueAvailable()) { + $value = $param->getDefaultValue(); + } + + $params[] = $value; + } + } + + return $params; + } + /** * Handle method response by auto-converting return values to HTTP responses. * @@ -456,7 +512,15 @@ protected function handleMethodResponse(mixed $result, string $methodName): void $responseBody = $responseBodyAttrs[0]->newInstance(); - // Auto-convert return value to response + // Handle custom content types + if ($responseBody->contentType !== 'application/json') { + // For non-JSON content types, send raw result + $content = is_string($result) ? $result : (is_array($result) || is_object($result) ? json_encode($result) : (string)$result); + $this->send($responseBody->contentType, $content, $responseBody->status); + return; + } + + // Auto-convert return value to JSON response if ($result === null) { // Null return = empty response with configured status $this->sendResponse('', $responseBody->status, $responseBody->type); @@ -522,13 +586,19 @@ private function configureParametersFromMethod(\ReflectionMethod $method): void foreach ($paramAttributes as $attribute) { $param = $attribute->newInstance(); + $options = [ + \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 + ]; + + if ($param->filter !== null) { + $options[\WebFiori\Http\ParamOption::FILTER] = $param->filter; + } + $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 - ] + $param->name => $options ]); } } @@ -617,6 +687,11 @@ public function addParameter($param) : bool { } if ($param instanceof RequestParameter && !$this->hasParameter($param->getName())) { + // Additional validation for reserved parameter names + if (in_array(strtolower($param->getName()), \WebFiori\Http\RequestParameter::RESERVED_NAMES)) { + throw new \InvalidArgumentException("Cannot add parameter '" . $param->getName() . "' to service '" . $this->getName() . "': parameter name is reserved. Reserved names: " . implode(', ', \WebFiori\Http\RequestParameter::RESERVED_NAMES)); + } + $this->parameters[] = $param; return true; diff --git a/WebFiori/Http/WebServicesManager.php b/WebFiori/Http/WebServicesManager.php index 1fe6c5f..c090446 100644 --- a/WebFiori/Http/WebServicesManager.php +++ b/WebFiori/Http/WebServicesManager.php @@ -2,7 +2,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/http/blob/master/LICENSE @@ -160,6 +160,40 @@ public function getResponse() : Response { public function addService(WebService $service) : WebServicesManager { return $this->addAction($service); } + /** + * Automatically discovers and registers web services from a directory. + * + * @param string $path The directory path to scan for service classes. Defaults to current directory. + * + * @return WebServicesManager Returns the same instance for method chaining. + */ + public function autoDiscoverServices(string $path = null) : WebServicesManager { + if ($path === null) { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + $path = dirname($trace[0]['file']); + } + + $files = glob($path . '/*.php'); + $beforeClasses = get_declared_classes(); + + foreach ($files as $file) { + require_once $file; + } + + $afterClasses = get_declared_classes(); + $newClasses = array_diff($afterClasses, $beforeClasses); + + foreach ($newClasses as $class) { + if (is_subclass_of($class, WebService::class)) { + $reflection = new \ReflectionClass($class); + if (!$reflection->isAbstract()) { + $this->addService(new $class()); + } + } + } + + return $this; + } /** * Sends a response message to indicate that request content type is * not supported by the API. @@ -393,7 +427,13 @@ public final function isContentTypeSupported() : bool { $rm = $this->getRequest()->getMethod(); if ($c !== null && ($rm == RequestMethod::POST || $rm == RequestMethod::PUT)) { - return in_array($c, self::POST_CONTENT_TYPES); + // Check if content type starts with any of the supported types + foreach (self::POST_CONTENT_TYPES as $supportedType) { + if (strpos($c, $supportedType) === 0) { + return true; + } + } + return false; } else if ($c === null && ($rm == RequestMethod::POST || $rm == RequestMethod::PUT)) { return false; } @@ -979,11 +1019,15 @@ private function filterInputsHelper() { if ($reqMeth == RequestMethod::GET || $reqMeth == RequestMethod::DELETE || - ($reqMeth == RequestMethod::PUT && $contentType != 'application/json') || - $reqMeth == RequestMethod::HEAD || - $reqMeth == RequestMethod::PATCH) { + $reqMeth == RequestMethod::HEAD) { $this->filter->filterGET(); - } else if ($reqMeth == RequestMethod::POST || ($reqMeth == RequestMethod::PUT && $contentType == 'application/json')) { + } else if ($reqMeth == RequestMethod::POST || + $reqMeth == RequestMethod::PUT || + $reqMeth == RequestMethod::PATCH) { + // Populate PUT/PATCH data before filtering + if ($reqMeth == RequestMethod::PUT || $reqMeth == RequestMethod::PATCH) { + $this->populatePutData($contentType); + } $this->filter->filterPOST(); } } @@ -1001,6 +1045,11 @@ private function filterInputsHelper() { * @deprecated since version 1.4.6 Use WebServicesManager::getCalledServiceName() instead. */ private function getAction() { + $services = $this->getServices(); + + if (count($services) == 1) { + return $services[array_keys($services)[0]]->getName(); + } $reqMeth = $this->getRequest()->getMethod(); $serviceIdx = ['action','service', 'service-name']; @@ -1034,15 +1083,92 @@ private function getAction() { $reqMeth == RequestMethod::TRACE || $reqMeth == RequestMethod::OPTIONS) && isset($_GET[$serviceNameIndex])) { $retVal = filter_var($_GET[$serviceNameIndex]); - } else if (($reqMeth == RequestMethod::POST || - $reqMeth == RequestMethod::PUT || - $reqMeth == RequestMethod::PATCH) && isset($_POST[$serviceNameIndex])) { + } else if ($reqMeth == RequestMethod::POST && isset($_POST[$serviceNameIndex])) { $retVal = filter_var($_POST[$serviceNameIndex]); + } else if ($reqMeth == RequestMethod::PUT || $reqMeth == RequestMethod::PATCH) { + $this->populatePutData($contentType); + if (isset($_POST[$serviceNameIndex])) { + $retVal = filter_var($_POST[$serviceNameIndex]); + } } } return $retVal; } + private function populatePutData(string $contentType) { + + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + $input = file_get_contents('php://input'); + + if (empty($input)) { + return; + } + + // Handle application/x-www-form-urlencoded + if (strpos($contentType, 'application/x-www-form-urlencoded') === 0) { + parse_str($input, $_POST); + return; + } + + // Handle multipart/form-data + if (strpos($contentType, 'multipart/form-data') === 0) { + // Extract boundary from content type + preg_match('/boundary=(.+)$/', $contentType, $matches); + if (!isset($matches[1])) { + return; + } + + $boundary = '--' . $matches[1]; + $parts = explode($boundary, $input); + + foreach ($parts as $part) { + if (trim($part) === '' || trim($part) === '--') { + continue; + } + + // Split headers and content + $sections = explode("\r\n\r\n", $part, 2); + if (count($sections) !== 2) { + continue; + } + + $headers = $sections[0]; + $content = rtrim($sections[1], "\r\n"); + + // Parse Content-Disposition header + if (preg_match('/name="([^"]+)"/', $headers, $nameMatch)) { + $fieldName = $nameMatch[1]; + + // Check if it's a file upload + if (preg_match('/filename="([^"]*)"/', $headers, $fileMatch)) { + // Handle file upload + $filename = $fileMatch[1]; + + // Extract content type if present + $fileType = 'application/octet-stream'; + if (preg_match('/Content-Type:\s*(.+)/i', $headers, $typeMatch)) { + $fileType = trim($typeMatch[1]); + } + + // Create temporary file + $tmpFile = tempnam(sys_get_temp_dir(), 'put_upload_'); + file_put_contents($tmpFile, $content); + + $_FILES[$fieldName] = [ + 'name' => $filename, + 'type' => $fileType, + 'tmp_name' => $tmpFile, + 'error' => UPLOAD_ERR_OK, + 'size' => strlen($content) + ]; + } else { + // Regular form field + $_POST[$fieldName] = $content; + } + } + } + } + } private function isAuth(WebService $service) { $isAuth = false; diff --git a/examples/00-basic/01-hello-world/HelloService.php b/examples/00-basic/01-hello-world/HelloService.php new file mode 100644 index 0000000..8ad02e5 --- /dev/null +++ b/examples/00-basic/01-hello-world/HelloService.php @@ -0,0 +1,23 @@ +setVersion('1.0.0'); +$manager->setDescription('Hello World API Example'); + +// Auto-discover and register services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/00-basic/02-with-parameters/GreetingService.php b/examples/00-basic/02-with-parameters/GreetingService.php new file mode 100644 index 0000000..0ca8698 --- /dev/null +++ b/examples/00-basic/02-with-parameters/GreetingService.php @@ -0,0 +1,29 @@ +setVersion('1.0.0'); +$manager->setDescription('Greeting API with Parameters'); + +// Auto-discover and register services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/00-basic/03-multiple-methods/README.md b/examples/00-basic/03-multiple-methods/README.md new file mode 100644 index 0000000..304e8d7 --- /dev/null +++ b/examples/00-basic/03-multiple-methods/README.md @@ -0,0 +1,81 @@ +# Multiple HTTP Methods + +Demonstrates handling different HTTP methods (GET, POST, PUT, DELETE) in a single service. + +## What This Example Demonstrates + +- Supporting multiple HTTP methods using separate mapping attributes +- Method-specific parameters with `#[RequestParam]` +- Different HTTP status codes per method (e.g., 201 for POST) +- RESTful CRUD operations (Create, Read, Update, Delete) +- Automatic service discovery + +## Files + +- [`TaskService.php`](TaskService.php) - Service supporting CRUD operations +- [`index.php`](index.php) - Main application entry point + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# GET - Retrieve tasks +curl "http://localhost:8080" + +# POST - Create task +curl -X POST -H "Content-Type: application/x-www-form-urlencoded" \ + -d "title=Buy groceries&description=Get milk and bread" \ + "http://localhost:8080" + +# PUT - Update task +curl -X PUT -H "Content-Type: application/x-www-form-urlencoded" \ + -d "id=1&title=Updated task" \ + "http://localhost:8080" + +# DELETE - Delete task +curl -X DELETE "http://localhost:8080?id=1" +``` + +**Expected Responses:** + +GET: +```json +{ + "message": "Tasks retrieved", + "http-code": 200, + "data": { + "tasks": [], + "count": 0 + } +} +``` + +POST: +```json +{ + "message": "Task created successfully", + "http-code": 201, + "data": { + "id": 1, + "title": "Buy groceries", + "description": "Get milk and bread" + } +} +``` + +## Code Explanation + +- Multiple HTTP methods are supported using separate mapping attributes: + - `#[GetMapping]` for GET requests + - `#[PostMapping]` for POST requests + - `#[PutMapping]` for PUT requests + - `#[DeleteMapping]` for DELETE requests +- Each method can have its own parameters defined with `#[RequestParam]` +- **Parameters are automatically injected** into method arguments (e.g., `createTask(string $title, ?string $description)`) +- `#[ResponseBody(status: 201)]` sets custom HTTP status code +- Service is auto-discovered using `autoDiscoverServices()` diff --git a/examples/00-basic/03-multiple-methods/TaskService.php b/examples/00-basic/03-multiple-methods/TaskService.php new file mode 100644 index 0000000..db80826 --- /dev/null +++ b/examples/00-basic/03-multiple-methods/TaskService.php @@ -0,0 +1,79 @@ + [], + 'count' => 0 + ]; + } + + #[PostMapping] + #[ResponseBody(status: 201)] + #[AllowAnonymous] + #[RequestParam('title', 'string', false, null, 'Task title (1-100 chars)')] + #[RequestParam('description', 'string', true, null, 'Task description (max 500 chars)')] + public function createTask(string $title, ?string $description = null): array { + if (!$title) { + throw new \InvalidArgumentException('Title is required for creating a task'); + } + + return [ + 'id' => 1, + 'title' => $title, + 'description' => $description ?: '' + ]; + } + + #[PutMapping] + #[ResponseBody] + #[AllowAnonymous] + #[RequestParam('id', 'int', false, null, 'Task ID')] + #[RequestParam('title', 'string', true, null, 'Updated task title')] + public function updateTask(int $id, ?string $title = null): array { + if (!$id) { + throw new \InvalidArgumentException('ID is required for updating a task'); + } + + return [ + 'id' => $id, + 'title' => $title, + 'updated_at' => date('Y-m-d H:i:s') + ]; + } + + #[DeleteMapping] + #[ResponseBody] + #[AllowAnonymous] + #[RequestParam('id', 'int', false, null, 'Task ID to delete')] + public function deleteTask(int $id): array { + if (!$id) { + throw new \InvalidArgumentException('ID is required for deleting a task'); + } + + return [ + 'id' => $id, + 'deleted_at' => date('Y-m-d H:i:s') + ]; + } +} diff --git a/examples/00-basic/03-multiple-methods/index.php b/examples/00-basic/03-multiple-methods/index.php new file mode 100644 index 0000000..a1ae0c0 --- /dev/null +++ b/examples/00-basic/03-multiple-methods/index.php @@ -0,0 +1,16 @@ +setVersion('1.0.0'); +$manager->setDescription('Task Management API'); + +// Auto-discover and register services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/00-basic/04-simple-manager/InfoService.php b/examples/00-basic/04-simple-manager/InfoService.php new file mode 100644 index 0000000..1744acb --- /dev/null +++ b/examples/00-basic/04-simple-manager/InfoService.php @@ -0,0 +1,33 @@ + 'Multi-Service API', + 'version' => '1.0.0', + 'description' => 'Demonstration of multiple services in one manager', + 'services' => ['info', 'users', 'products'], + 'endpoints' => [ + 'GET /info' => 'Get API information', + 'GET /users' => 'Get all users', + 'GET /products' => 'Get all products' + ] + ]; + } +} diff --git a/examples/00-basic/04-simple-manager/ProductService.php b/examples/00-basic/04-simple-manager/ProductService.php new file mode 100644 index 0000000..520272f --- /dev/null +++ b/examples/00-basic/04-simple-manager/ProductService.php @@ -0,0 +1,28 @@ + [ + ['id' => 1, 'name' => 'Laptop', 'price' => 999.99], + ['id' => 2, 'name' => 'Mouse', 'price' => 29.99] + ] + ]; + } +} diff --git a/examples/00-basic/04-simple-manager/README.md b/examples/00-basic/04-simple-manager/README.md new file mode 100644 index 0000000..5c38a77 --- /dev/null +++ b/examples/00-basic/04-simple-manager/README.md @@ -0,0 +1,57 @@ +# Simple Service Manager + +Demonstrates managing multiple services with a single WebServicesManager instance. + +## What This Example Demonstrates + +- Auto-discovering and registering multiple services +- Service routing with `?service=` parameter +- Manager configuration (version, description) +- Multiple independent services in one application + +## Files + +- [`UserService.php`](UserService.php) - User management service +- [`ProductService.php`](ProductService.php) - Product management service +- [`InfoService.php`](InfoService.php) - API information service +- [`index.php`](index.php) - Main application with multiple services + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# Get API information +curl "http://localhost:8080?service=info" + +# User service +curl "http://localhost:8080?service=users" + +# Product service +curl "http://localhost:8080?service=products" +``` + +**Expected Responses:** + +API Info: +```json +{ + "message": "Success", + "type": "success", + "http-code": 200, + "more-info": [...] +} +``` + +## Code Explanation + +- Multiple services are auto-discovered and registered with `autoDiscoverServices()` +- Each service has its own unique name and functionality +- The manager automatically routes requests to the appropriate service: + - **GET/DELETE**: `?service=` query parameter + - **POST/PUT/PATCH**: `service` in request body (form data or JSON attribute) +- When multiple services exist, the `service` parameter is required for routing diff --git a/examples/00-basic/04-simple-manager/UserService.php b/examples/00-basic/04-simple-manager/UserService.php new file mode 100644 index 0000000..87ad758 --- /dev/null +++ b/examples/00-basic/04-simple-manager/UserService.php @@ -0,0 +1,28 @@ + [ + ['id' => 1, 'name' => 'John Doe'], + ['id' => 2, 'name' => 'Jane Smith'] + ] + ]; + } +} diff --git a/examples/00-basic/04-simple-manager/index.php b/examples/00-basic/04-simple-manager/index.php new file mode 100644 index 0000000..4121220 --- /dev/null +++ b/examples/00-basic/04-simple-manager/index.php @@ -0,0 +1,16 @@ +setVersion('1.0.0'); +$manager->setDescription('Multi-Service API Example'); + +// Auto-discover and register services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/00-basic/README.md b/examples/00-basic/README.md new file mode 100644 index 0000000..d0bf3df --- /dev/null +++ b/examples/00-basic/README.md @@ -0,0 +1,29 @@ +# Basic Examples + +This folder contains the most fundamental examples to get started with WebFiori HTTP library. + +## Examples + +1. **[01-hello-world](01-hello-world/)** - Minimal service implementation +2. **[02-with-parameters](02-with-parameters/)** - Basic parameter handling +3. **[03-multiple-methods](03-multiple-methods/)** - Supporting different HTTP methods +4. **[04-simple-manager](04-simple-manager/)** - Basic service manager setup + +## Prerequisites + +- PHP 8.1+ +- WebFiori HTTP library installed via Composer + +## Running Examples + +Each example can be run independently: + +```bash +cd 01-hello-world +php -S localhost:8080 +``` + +Then test with: +```bash +curl "http://localhost:8080?service=hello" +``` diff --git a/examples/01-core/01-parameter-validation/README.md b/examples/01-core/01-parameter-validation/README.md new file mode 100644 index 0000000..8fcba0d --- /dev/null +++ b/examples/01-core/01-parameter-validation/README.md @@ -0,0 +1,62 @@ +# Parameter Validation + +Demonstrates comprehensive parameter validation including types, ranges, custom filters, and validation rules. + +## What This Example Demonstrates + +- Different parameter types (string, int, email, URL) +- Validation rules (min/max length, ranges) +- **Custom validation filters using the `filter` parameter** +- Default values for optional parameters +- Validation error handling +- Automatic parameter injection + +## Files + +- [`ValidationService.php`](ValidationService.php) - Service with comprehensive validation +- [`index.php`](index.php) - Main application entry point + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# Valid request +curl "http://localhost:8080?name=John&age=25&email=john@example.com&website=https://example.com" + +# Valid with custom username validation +curl "http://localhost:8080?name=John&email=john@test.com&username=john123" + +# Invalid username (too short) +curl "http://localhost:8080?name=John&email=john@test.com&username=ab" + +# Invalid username (special characters) +curl "http://localhost:8080?name=John&email=john@test.com&username=john@123" + +# Invalid email format +curl "http://localhost:8080?name=John&age=25&email=invalid-email" + +# Missing required parameter +curl "http://localhost:8080?age=25" +``` + +## Code Explanation + +- Different parameter types are defined using `#[RequestParam]` attributes +- Parameters are automatically injected into method arguments with proper types +- **Custom validation** can be added using the `filter` parameter: + ```php + #[RequestParam('username', 'string', true, null, 'Description', filter: [ClassName::class, 'methodName'])] + ``` +- Validation happens automatically before method execution: + - Type validation (string, int, email, url, double) + - Range validation (min/max for numbers) + - Length validation (min/max for strings) + - Format validation (email, URL) + - Custom validation via filter functions +- Invalid parameters automatically return 400 errors with details +- Service is auto-discovered using `autoDiscoverServices()` diff --git a/examples/01-core/01-parameter-validation/ValidationService.php b/examples/01-core/01-parameter-validation/ValidationService.php new file mode 100644 index 0000000..46af84f --- /dev/null +++ b/examples/01-core/01-parameter-validation/ValidationService.php @@ -0,0 +1,63 @@ + [ + 'name' => $name, + 'age' => $age, + 'email' => $email, + 'website' => $website, + 'score' => $score, + 'username' => $username + ], + 'validation_info' => [ + 'name_length' => strlen($name), + 'age_valid' => $age >= 18 && $age <= 120, + 'email_domain' => substr(strrchr($email, '@'), 1), + 'website_protocol' => $website ? parse_url($website, PHP_URL_SCHEME) : null, + 'username_valid' => $username ? ctype_alnum($username) : null + ] + ]; + } + + /** + * Custom validation function for username + */ + public static function validateUsername($original, $filtered, $param) { + // Must be alphanumeric and 3-20 characters + if (strlen($filtered) < 3 || strlen($filtered) > 20) { + return APIFilter::INVALID; + } + + if (!ctype_alnum($filtered)) { + return APIFilter::INVALID; + } + + return strtolower($filtered); // Normalize to lowercase + } +} diff --git a/examples/01-core/01-parameter-validation/index.php b/examples/01-core/01-parameter-validation/index.php new file mode 100644 index 0000000..9a55138 --- /dev/null +++ b/examples/01-core/01-parameter-validation/index.php @@ -0,0 +1,16 @@ +setVersion('1.0.0'); +$manager->setDescription('Parameter Validation API'); + +// Auto-discover and register services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/01-core/02-error-handling/ErrorService.php b/examples/01-core/02-error-handling/ErrorService.php new file mode 100644 index 0000000..1817daf --- /dev/null +++ b/examples/01-core/02-error-handling/ErrorService.php @@ -0,0 +1,77 @@ +handleSuccess(); + case 'validate': + return $this->handleValidation($age); + case 'divide': + return $this->handleDivision($a, $b); + case 'not-found': + throw new \Exception('The requested resource was not found', 404); + case 'server-error': + throw new \Exception('Simulated server error for testing purposes', 500); + case 'unauthorized': + throw new \Exception('Access denied: insufficient permissions', 403); + default: + throw new \InvalidArgumentException("Unknown operation: $operation. Available: success, validate, divide, not-found, server-error, unauthorized"); + } + } + + private function handleSuccess(): array { + return [ + 'timestamp' => date('Y-m-d H:i:s'), + 'status' => 'OK' + ]; + } + + private function handleValidation(?int $age): array { + if ($age === null) { + throw new \InvalidArgumentException('Age parameter is required for validation test'); + } + + if ($age < 18) { + throw new \InvalidArgumentException("Age must be 18 or older. Provided: $age"); + } + + return ['validated_age' => $age]; + } + + private function handleDivision(?float $a, ?float $b): array { + if ($a === null || $b === null) { + throw new \InvalidArgumentException('Both parameters a and b are required for division'); + } + + if ($b == 0) { + throw new \InvalidArgumentException('Division by zero is not allowed'); + } + + return [ + 'operands' => ['a' => $a, 'b' => $b], + 'result' => $a / $b + ]; + } +} diff --git a/examples/01-core/02-error-handling/README.md b/examples/01-core/02-error-handling/README.md new file mode 100644 index 0000000..1535bff --- /dev/null +++ b/examples/01-core/02-error-handling/README.md @@ -0,0 +1,49 @@ +# Error Handling + +Demonstrates custom error responses, validation error handling, and proper HTTP status codes. + +## What This Example Demonstrates + +- Custom error messages and status codes +- Validation error handling +- Exception handling in services +- Structured error responses + +## Files + +- [`ErrorService.php`](ErrorService.php) - Service with comprehensive error handling +- [`index.php`](index.php) - Main application entry point + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# Valid request +curl "http://localhost:8080?operation=success" + +# Validation error +curl "http://localhost:8080?operation=validate&age=15" + +# Business logic error +curl "http://localhost:8080?operation=divide&a=10&b=0" + +# Not found error +curl "http://localhost:8080?operation=not-found" + +# Server error simulation +curl "http://localhost:8080?operation=server-error" +``` + +## Code Explanation + +- Custom error responses with appropriate HTTP status codes +- Exceptions are automatically caught and converted to error responses +- Parameters are automatically injected into method arguments +- Business logic errors handled with try-catch blocks or thrown exceptions +- Consistent error response format across all error types +- Service is auto-discovered using `autoDiscoverServices()` diff --git a/examples/01-core/02-error-handling/index.php b/examples/01-core/02-error-handling/index.php new file mode 100644 index 0000000..b419819 --- /dev/null +++ b/examples/01-core/02-error-handling/index.php @@ -0,0 +1,16 @@ +setVersion('1.0.0'); +$manager->setDescription('Error Handling API'); + +// Auto-discover and register services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/01-core/03-json-requests/JsonService.php b/examples/01-core/03-json-requests/JsonService.php new file mode 100644 index 0000000..2c7600b --- /dev/null +++ b/examples/01-core/03-json-requests/JsonService.php @@ -0,0 +1,109 @@ +processJsonRequest($operation); + } + + #[PutMapping] + #[ResponseBody] + #[AllowAnonymous] + #[RequestParam('operation', 'string', true, 'update', 'Operation to perform')] + public function processJsonPut(?string $operation = 'update'): array { + return $this->processJsonRequest($operation); + } + + private function processJsonRequest(string $operation): array { + $inputs = $this->getInputs(); + + // Check if we received JSON data + if (!($inputs instanceof Json)) { + throw new \InvalidArgumentException('This service expects JSON data'); + } + + switch ($operation) { + case 'create': + return $this->handleCreate($inputs); + case 'update': + return $this->handleUpdate($inputs); + case 'validate': + return $this->handleValidate($inputs); + default: + return $this->handleGeneric($inputs, $operation); + } + } + + private function handleCreate(Json $jsonData): array { + $user = $jsonData->get('user'); + $preferences = $jsonData->get('preferences'); + + return [ + 'operation' => 'create', + 'user_data' => $user, + 'preferences' => $preferences, + 'created_at' => date('Y-m-d H:i:s') + ]; + } + + private function handleUpdate(Json $jsonData): array { + $name = $jsonData->get('name'); + $email = $jsonData->get('email'); + + return [ + 'operation' => 'update', + 'updated_fields' => [ + 'name' => $name, + 'email' => $email + ], + 'updated_at' => date('Y-m-d H:i:s') + ]; + } + + private function handleValidate(Json $jsonData): array { + $errors = []; + + // Validate required fields + if (!$jsonData->hasKey('name') || empty($jsonData->get('name'))) { + $errors[] = 'Name is required'; + } + + if (!$jsonData->hasKey('email') || !filter_var($jsonData->get('email'), FILTER_VALIDATE_EMAIL)) { + $errors[] = 'Valid email is required'; + } + + if (!empty($errors)) { + throw new \InvalidArgumentException('Validation failed: ' . implode(', ', $errors)); + } + + return ['validated_data' => $jsonData->toArray()]; + } + + private function handleGeneric(Json $jsonData, string $operation): array { + return [ + 'operation' => $operation, + 'received_json' => $jsonData->toArray(), + 'json_keys' => array_keys($jsonData->toArray()), + 'processed_at' => date('Y-m-d H:i:s') + ]; + } +} diff --git a/examples/01-core/03-json-requests/README.md b/examples/01-core/03-json-requests/README.md new file mode 100644 index 0000000..61e37c1 --- /dev/null +++ b/examples/01-core/03-json-requests/README.md @@ -0,0 +1,62 @@ +# JSON Request Handling + +Demonstrates handling JSON request bodies and automatic parsing. + +## What This Example Demonstrates + +- Processing JSON request bodies +- Content-Type: application/json handling +- Accessing JSON data in services +- Mixed parameter sources (URL + JSON body) + +## Files + +- [`JsonService.php`](JsonService.php) - Service that processes JSON requests +- [`index.php`](index.php) - Main application entry point + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# JSON POST request +curl -X POST "http://localhost:8080" \ + -H "Content-Type: application/json" \ + -d '{"user": {"name": "John", "age": 30}, "preferences": {"theme": "dark", "notifications": true}}' + +# JSON with operation parameter +curl -X POST "http://localhost:8080?operation=update" \ + -H "Content-Type: application/json" \ + -d '{"name": "Updated Name", "email": "new@example.com"}' + +# JSON PUT request +curl -X PUT "http://localhost:8080" \ + -H "Content-Type: application/json" \ + -d '{"name": "Updated", "email": "updated@example.com"}' +``` + +**Expected Response:** +```json +{ + "message": "JSON data processed successfully", + "http-code": 200, + "data": { + "received_json": {...}, + "url_params": {...}, + "processed_at": "2024-01-01 12:00:00" + } +} +``` + +## Code Explanation + +- JSON requests are automatically parsed when Content-Type is application/json +- `getInputs()` returns the parsed JSON object +- Parameters can be injected into method arguments +- Different methods handle POST and PUT requests separately +- Invalid JSON automatically returns 400 Bad Request +- Service is auto-discovered using `autoDiscoverServices()` diff --git a/examples/01-core/03-json-requests/index.php b/examples/01-core/03-json-requests/index.php new file mode 100644 index 0000000..3dadb31 --- /dev/null +++ b/examples/01-core/03-json-requests/index.php @@ -0,0 +1,16 @@ +setVersion('1.0.0'); +$manager->setDescription('JSON Request Handling API'); + +// Auto-discover and register services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/01-core/04-file-uploads/README.md b/examples/01-core/04-file-uploads/README.md new file mode 100644 index 0000000..e7c7dff --- /dev/null +++ b/examples/01-core/04-file-uploads/README.md @@ -0,0 +1,60 @@ +# File Upload Handling + +Demonstrates handling file uploads with multipart/form-data requests. + +## What This Example Demonstrates + +- File upload handling with $_FILES +- File validation (size, type, extension) +- Multiple file uploads +- File storage and metadata + +## Files + +- [`UploadService.php`](UploadService.php) - File upload handling service +- [`index.php`](index.php) - Main application entry point + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# Single file upload +curl -X POST "http://localhost:8080" \ + -F "file=@/path/to/your/file.txt" \ + -F "description=Test file upload" + +# Upload with metadata +curl -X POST "http://localhost:8080?operation=with-metadata" \ + -F "file=@/path/to/document.pdf" \ + -F "title=Important Document" \ + -F "category=documents" +``` + +**Expected Response:** +```json +{ + "message": "File uploaded successfully", + "http-code": 200, + "data": { + "filename": "file.txt", + "size": 1024, + "type": "text/plain", + "upload_path": "uploads/file_20240101_120000.txt" + } +} +``` + +## Code Explanation + +- File uploads are handled through $_FILES superglobal +- Parameters are automatically injected into method arguments +- File validation includes size, type, and extension checks +- Files are moved to a secure upload directory +- Metadata is stored alongside file information +- Content type validation now properly handles multipart/form-data with boundary parameters +- Service is auto-discovered using `autoDiscoverServices()` diff --git a/examples/01-core/04-file-uploads/UploadService.php b/examples/01-core/04-file-uploads/UploadService.php new file mode 100644 index 0000000..cd25400 --- /dev/null +++ b/examples/01-core/04-file-uploads/UploadService.php @@ -0,0 +1,192 @@ +handleSingleUpload($description); + case 'multiple': + return $this->handleMultipleUpload(); + case 'with-metadata': + return $this->handleUploadWithMetadata($title, $category); + default: + throw new \InvalidArgumentException('Unknown upload operation'); + } + } + + private function handleSingleUpload(?string $description): array { + if (!isset($_FILES['file'])) { + throw new \InvalidArgumentException('No file uploaded. Expected field: file'); + } + + $file = $_FILES['file']; + + $result = $this->processFile($file); + + if (!$result['success']) { + throw new \InvalidArgumentException($result['error']); + } + + return [ + 'file_info' => $result['data'], + 'description' => $description, + 'uploaded_at' => date('Y-m-d H:i:s') + ]; + } + + private function handleMultipleUpload(): array { + if (!isset($_FILES['files'])) { + throw new \InvalidArgumentException('No files uploaded. Expected field: files[]'); + } + + $files = $_FILES['files']; + $results = []; + $errors = []; + + for ($i = 0; $i < count($files['name']); $i++) { + $file = [ + 'name' => $files['name'][$i], + 'type' => $files['type'][$i], + 'tmp_name' => $files['tmp_name'][$i], + 'error' => $files['error'][$i], + 'size' => $files['size'][$i] + ]; + + $result = $this->processFile($file); + + if ($result['success']) { + $results[] = $result['data']; + } else { + $errors[] = [ + 'filename' => $file['name'], + 'error' => $result['error'] + ]; + } + } + + return [ + 'uploaded_files' => $results, + 'errors' => $errors, + 'total_uploaded' => count($results), + 'total_errors' => count($errors) + ]; + } + + private function handleUploadWithMetadata(?string $title, ?string $category): array { + if (!isset($_FILES['file'])) { + throw new \InvalidArgumentException('No file uploaded'); + } + + $file = $_FILES['file']; + + $result = $this->processFile($file); + + if (!$result['success']) { + throw new \InvalidArgumentException($result['error']); + } + + return [ + 'title' => $title ?: $file['name'], + 'category' => $category ?: 'general', + 'uploaded_by' => 'anonymous', + 'upload_date' => date('Y-m-d H:i:s'), + 'file_info' => $result['data'] + ]; + } + + private function processFile(array $file): array { + // Check for upload errors + if ($file['error'] !== UPLOAD_ERR_OK) { + return [ + 'success' => false, + 'error' => 'File upload error', + 'details' => ['upload_error_code' => $file['error']] + ]; + } + + // Validate file size + if ($file['size'] > self::MAX_FILE_SIZE) { + return [ + 'success' => false, + 'error' => 'File too large', + 'details' => [ + 'max_size' => self::MAX_FILE_SIZE, + 'file_size' => $file['size'] + ] + ]; + } + + // Validate file type + if (!in_array($file['type'], self::ALLOWED_TYPES)) { + return [ + 'success' => false, + 'error' => 'File type not allowed', + 'details' => [ + 'allowed_types' => self::ALLOWED_TYPES, + 'file_type' => $file['type'] + ] + ]; + } + + // Generate unique filename + $extension = pathinfo($file['name'], PATHINFO_EXTENSION); + $filename = pathinfo($file['name'], PATHINFO_FILENAME); + $uniqueFilename = $filename . '_' . date('Ymd_His') . '.' . $extension; + $uploadPath = self::UPLOAD_DIR . $uniqueFilename; + + // Move uploaded file + if (move_uploaded_file($file['tmp_name'], $uploadPath)) { + return [ + 'success' => true, + 'data' => [ + 'original_name' => $file['name'], + 'stored_name' => $uniqueFilename, + 'size' => $file['size'], + 'type' => $file['type'], + 'upload_path' => $uploadPath, + 'url' => '/' . $uploadPath + ] + ]; + } else { + return [ + 'success' => false, + 'error' => 'Failed to save file', + 'details' => ['upload_path' => $uploadPath] + ]; + } + } +} diff --git a/examples/01-core/04-file-uploads/index.php b/examples/01-core/04-file-uploads/index.php new file mode 100644 index 0000000..fb4fc16 --- /dev/null +++ b/examples/01-core/04-file-uploads/index.php @@ -0,0 +1,16 @@ +setVersion('1.0.0'); +$manager->setDescription('File Upload API'); + +// Auto-discover and register services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/01-core/05-response-formats/JsonResponseService.php b/examples/01-core/05-response-formats/JsonResponseService.php new file mode 100644 index 0000000..a9dad77 --- /dev/null +++ b/examples/01-core/05-response-formats/JsonResponseService.php @@ -0,0 +1,28 @@ + 'Hello from WebFiori HTTP', + 'timestamp' => date('Y-m-d H:i:s'), + 'format' => 'json', + 'server_info' => [ + 'php_version' => PHP_VERSION, + 'server_time' => time() + ] + ]; + } +} diff --git a/examples/01-core/05-response-formats/README.md b/examples/01-core/05-response-formats/README.md new file mode 100644 index 0000000..ad38df4 --- /dev/null +++ b/examples/01-core/05-response-formats/README.md @@ -0,0 +1,65 @@ +# Response Formats + +Demonstrates different response content types and formats beyond JSON. + +## What This Example Demonstrates + +- Different content types using `#[ResponseBody(contentType: '...')]` +- Separate services for each format (cleaner architecture) +- Unified service with format switching (single endpoint) +- Custom response serialization (XML, Text) +- Automatic content type handling + +## Files + +- [`ResponseService.php`](ResponseService.php) - Original unified service (demonstrates format switching) +- [`JsonResponseService.php`](JsonResponseService.php) - JSON response service +- [`XmlResponseService.php`](XmlResponseService.php) - XML response service +- [`TextResponseService.php`](TextResponseService.php) - Plain text response service +- [`index.php`](index.php) - Main application entry point + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# JSON response +curl "http://localhost:8080?service=json-response" + +# XML response +curl "http://localhost:8080?service=xml-response" + +# Plain text response +curl "http://localhost:8080?service=text-response" + +# Original unified service with format parameter +curl "http://localhost:8080?service=response&format=json" +curl "http://localhost:8080?service=response&format=xml" +curl "http://localhost:8080?service=response&format=text" +``` + +## Code Explanation + +**Two approaches demonstrated:** + +1. **Separate Services** (Recommended for clean architecture): + - `JsonResponseService` - Returns JSON with default content type + - `XmlResponseService` - Uses `#[ResponseBody(contentType: 'application/xml')]` + - `TextResponseService` - Uses `#[ResponseBody(contentType: 'text/plain')]` + - Each service is focused and simple + - Auto-discovered and registered automatically + +2. **Unified Service** (Single endpoint with format switching): + - `ResponseService` - Handles multiple formats via parameter + - Uses `send()` method for custom content types + - More complex but provides single endpoint + +**Benefits of separate services:** +- Cleaner, more maintainable code +- Each service has single responsibility +- Easier to test and modify +- Better separation of concerns diff --git a/examples/01-core/05-response-formats/ResponseService.php b/examples/01-core/05-response-formats/ResponseService.php new file mode 100644 index 0000000..c32123a --- /dev/null +++ b/examples/01-core/05-response-formats/ResponseService.php @@ -0,0 +1,214 @@ +getSampleData($data, $format); + + // Dynamically set content type based on format + $reflection = new \ReflectionMethod($this, 'handleResponse'); + $attrs = $reflection->getAttributes(\WebFiori\Http\Annotations\ResponseBody::class); + + switch ($format) { + case 'xml': + // Override response to XML + $xml = $this->arrayToXml($sampleData, 'response'); + $this->send('application/xml', $xml, 200); + return null; + case 'text': + // Override response to text + $text = $this->arrayToText($sampleData); + $this->send('text/plain', $text, 200); + return null; + default: + // Return array for JSON (handled by ResponseBody) + return $sampleData; + } + } + + private function getSampleData(string $type, string $format): array { + switch ($type) { + case 'users': + return [ + ['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com'], + ['id' => 2, 'name' => 'Jane Smith', 'email' => 'jane@example.com'], + ['id' => 3, 'name' => 'Bob Johnson', 'email' => 'bob@example.com'] + ]; + case 'products': + return [ + ['id' => 1, 'name' => 'Laptop', 'price' => 999.99, 'category' => 'Electronics'], + ['id' => 2, 'name' => 'Book', 'price' => 19.99, 'category' => 'Education'], + ['id' => 3, 'name' => 'Coffee Mug', 'price' => 12.50, 'category' => 'Kitchen'] + ]; + default: + return [ + 'message' => 'Hello from WebFiori HTTP', + 'timestamp' => date('Y-m-d H:i:s'), + 'format_requested' => $format, + 'server_info' => [ + 'php_version' => PHP_VERSION, + 'server_time' => time() + ] + ]; + } + } + + private function arrayToXml(array $data, string $rootElement = 'root'): string { + $xml = "\n"; + $xml .= "<$rootElement>\n"; + $xml .= $this->arrayToXmlRecursive($data, 1); + $xml .= ""; + return $xml; + } + + private function arrayToXmlRecursive(array $data, int $indent = 0): string { + $xml = ''; + $spaces = str_repeat(' ', $indent); + + foreach ($data as $key => $value) { + $key = is_numeric($key) ? 'item' : $key; + + if (is_array($value)) { + $xml .= "$spaces<$key>\n"; + $xml .= $this->arrayToXmlRecursive($value, $indent + 1); + $xml .= "$spaces\n"; + } else { + $xml .= "$spaces<$key>" . htmlspecialchars($value) . "\n"; + } + } + + return $xml; + } + + private function arrayToText(array $data): string { + return $this->arrayToTextRecursive($data, 0); + } + + private function arrayToTextRecursive(array $data, int $indent = 0): string { + $text = ''; + $spaces = str_repeat(' ', $indent); + + foreach ($data as $key => $value) { + if (is_array($value)) { + $text .= "$spaces$key:\n"; + $text .= $this->arrayToTextRecursive($value, $indent + 1); + } else { + $text .= "$spaces$key: $value\n"; + } + } + + return $text; + } + + private function arrayToHtml(array $data): string { + $html = "\n\n\n"; + $html .= "WebFiori HTTP Response\n"; + $html .= "\n"; + $html .= "\n\n"; + $html .= "

WebFiori HTTP Response

\n"; + $html .= "

Generated: " . date('Y-m-d H:i:s') . "

\n"; + $html .= $this->arrayToHtmlTable($data); + $html .= "\n"; + return $html; + } + + private function arrayToHtmlTable(array $data): string { + if (empty($data)) { + return '

No data available

'; + } + + // Check if it's a list of objects/arrays + if (is_array($data[0] ?? null)) { + return $this->createHtmlTable($data); + } + + // Simple key-value pairs + $html = "\n"; + foreach ($data as $key => $value) { + $html .= ""; + $html .= "\n"; + } + $html .= "
" . htmlspecialchars($key) . "" . htmlspecialchars(is_array($value) ? json_encode($value) : $value) . "
\n"; + + return $html; + } + + private function createHtmlTable(array $data): string { + if (empty($data)) { + return '

No data available

'; + } + + $html = "\n\n"; + + // Get headers from first row + $headers = array_keys($data[0]); + foreach ($headers as $header) { + $html .= ""; + } + $html .= "\n\n\n"; + + // Add data rows + foreach ($data as $row) { + $html .= ""; + foreach ($headers as $header) { + $value = $row[$header] ?? ''; + $html .= ""; + } + $html .= "\n"; + } + + $html .= "\n
" . htmlspecialchars($header) . "
" . htmlspecialchars($value) . "
\n"; + return $html; + } + + private function arrayToCsv(array $data): string { + if (empty($data)) { + return ''; + } + + $csv = ''; + + // Check if it's a list of objects/arrays + if (is_array($data[0] ?? null)) { + // Get headers + $headers = array_keys($data[0]); + $csv .= implode(',', $headers) . "\n"; + + // Add data rows + foreach ($data as $row) { + $csvRow = []; + foreach ($headers as $header) { + $value = $row[$header] ?? ''; + $csvRow[] = '"' . str_replace('"', '""', $value) . '"'; + } + $csv .= implode(',', $csvRow) . "\n"; + } + } else { + // Simple key-value pairs + $csv .= "Key,Value\n"; + foreach ($data as $key => $value) { + $csv .= '"' . str_replace('"', '""', $key) . '","' . str_replace('"', '""', $value) . "\"\n"; + } + } + + return $csv; + } +} diff --git a/examples/01-core/05-response-formats/TextResponseService.php b/examples/01-core/05-response-formats/TextResponseService.php new file mode 100644 index 0000000..d86f417 --- /dev/null +++ b/examples/01-core/05-response-formats/TextResponseService.php @@ -0,0 +1,32 @@ + 'Hello from WebFiori HTTP', + 'timestamp' => date('Y-m-d H:i:s'), + 'format' => 'text', + 'php_version' => PHP_VERSION, + 'server_time' => time() + ]; + + $text = ''; + foreach ($data as $key => $value) { + $text .= "$key: $value\n"; + } + return $text; + } +} diff --git a/examples/01-core/05-response-formats/XmlResponseService.php b/examples/01-core/05-response-formats/XmlResponseService.php new file mode 100644 index 0000000..9701650 --- /dev/null +++ b/examples/01-core/05-response-formats/XmlResponseService.php @@ -0,0 +1,47 @@ + 'Hello from WebFiori HTTP', + 'timestamp' => date('Y-m-d H:i:s'), + 'format' => 'xml', + 'server_info' => [ + 'php_version' => PHP_VERSION, + 'server_time' => time() + ] + ]; + + return $this->arrayToXml($data); + } + + private function arrayToXml(array $data, string $root = 'response'): string { + $xml = "\n<$root>\n"; + foreach ($data as $key => $value) { + if (is_array($value)) { + $xml .= " <$key>\n"; + foreach ($value as $k => $v) { + $xml .= " <$k>" . htmlspecialchars($v) . "\n"; + } + $xml .= " \n"; + } else { + $xml .= " <$key>" . htmlspecialchars($value) . "\n"; + } + } + $xml .= ""; + return $xml; + } +} diff --git a/examples/01-core/05-response-formats/index.php b/examples/01-core/05-response-formats/index.php new file mode 100644 index 0000000..409c426 --- /dev/null +++ b/examples/01-core/05-response-formats/index.php @@ -0,0 +1,16 @@ +setVersion('1.0.0'); +$manager->setDescription('Response Formats API'); + +// Auto-discover and register services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/01-core/README.md b/examples/01-core/README.md new file mode 100644 index 0000000..2a76ddd --- /dev/null +++ b/examples/01-core/README.md @@ -0,0 +1,24 @@ +# Core Features Examples + +This folder demonstrates core WebFiori HTTP features including parameter validation, error handling, and different content types. + +## Examples + +1. **[01-parameter-validation](01-parameter-validation/)** - Advanced parameter validation and filtering +2. **[02-error-handling](02-error-handling/)** - Custom error responses and validation +3. **[03-json-requests](03-json-requests/)** - Handling JSON request bodies +4. **[04-file-uploads](04-file-uploads/)** - File upload handling with multipart forms +5. **[05-response-formats](05-response-formats/)** - Different response content types + +## Prerequisites + +- Completed basic examples (00-basic) +- Understanding of HTTP request/response concepts + +## Key Concepts + +- Parameter types and validation rules +- Custom error messages and status codes +- JSON request body parsing +- File upload handling +- Content negotiation diff --git a/examples/02-security/01-basic-auth/BasicAuthService.php b/examples/02-security/01-basic-auth/BasicAuthService.php new file mode 100644 index 0000000..a6c0202 --- /dev/null +++ b/examples/02-security/01-basic-auth/BasicAuthService.php @@ -0,0 +1,104 @@ + 'password123', + 'user' => 'userpass', + 'guest' => 'guestpass' + ]; + + #[GetMapping] + #[ResponseBody] + #[RequiresAuth] + public function getSecureData(): array { + // Get authenticated user info + $authHeader = $this->getAuthHeader(); + $credentials = base64_decode($authHeader->getCredentials()); + [$username] = explode(':', $credentials, 2); + + return [ + 'user' => $username, + 'authenticated_at' => date('Y-m-d H:i:s'), + 'auth_method' => 'basic', + 'secure_data' => [ + 'secret_key' => 'abc123xyz', + 'access_level' => $this->getUserAccessLevel($username), + 'session_id' => uniqid('sess_') + ] + ]; + } + + public function isAuthorized(): bool { + $authHeader = $this->getAuthHeader(); + + if ($authHeader === null) { + return false; + } + + $scheme = $authHeader->getScheme(); + $credentials = $authHeader->getCredentials(); + + // Check if it's Basic authentication + if ($scheme !== 'basic') { + return false; + } + + // Decode base64 credentials + $decoded = base64_decode($credentials); + + if ($decoded === false) { + return false; + } + + // Split username and password + $parts = explode(':', $decoded, 2); + + if (count($parts) !== 2) { + return false; + } + + [$username, $password] = $parts; + + // Validate credentials + return $this->validateUser($username, $password); + } + + private function validateUser(string $username, string $password): bool { + if (!isset(self::USERS[$username])) { + return false; + } + + if (self::USERS[$username] !== $password) { + return false; + } + + return true; + } + + private function getUserAccessLevel(string $username): string { + switch ($username) { + case 'admin': + return 'administrator'; + case 'user': + return 'standard_user'; + case 'guest': + return 'read_only'; + default: + return 'unknown'; + } + } +} diff --git a/examples/02-security/01-basic-auth/README.md b/examples/02-security/01-basic-auth/README.md new file mode 100644 index 0000000..445cdcf --- /dev/null +++ b/examples/02-security/01-basic-auth/README.md @@ -0,0 +1,68 @@ +# Basic Authentication + +Demonstrates HTTP Basic authentication with username/password credentials. + +## What This Example Demonstrates + +- HTTP Basic authentication implementation +- Base64 credential decoding +- User credential validation +- Custom authentication error messages + +## Files + +- [`BasicAuthService.php`](BasicAuthService.php) - Service with Basic auth +- [`index.php`](index.php) - Main application entry point + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# Without authentication (will fail) +curl "http://localhost:8080" + +# With Basic authentication +curl -u "admin:password123" "http://localhost:8080" + +# With invalid credentials +curl -u "admin:wrongpassword" "http://localhost:8080" +``` + +**Expected Responses:** + +Without auth: +```json +{ + "message": "Authentication required", + "type": "error", + "http-code": 401 +} +``` + +With valid auth: +```json +{ + "message": "Access granted to secure resource", + "http-code": 200, + "data": { + "user": "admin", + "authenticated_at": "2024-01-01 12:00:00" + } +} +``` + +## Code Explanation + +- `isAuthorized()` method implements custom authentication logic +- `getAuthHeader()` retrieves the Authorization header +- Basic auth credentials are base64 encoded as "username:password" +- Custom user validation logic checks against predefined credentials +- `#[RequiresAuth]` calls `isAuthorized()` first, then checks `#[PreAuthorize]` if present +- **Authorization flow:** `isAuthorized()` → `#[PreAuthorize]` (if exists) → SecurityContext +- Service is auto-discovered using `autoDiscoverServices()` +- Different users get different access levels diff --git a/examples/02-security/01-basic-auth/index.php b/examples/02-security/01-basic-auth/index.php new file mode 100644 index 0000000..88b28bf --- /dev/null +++ b/examples/02-security/01-basic-auth/index.php @@ -0,0 +1,16 @@ +setVersion('1.0.0'); +$manager->setDescription('Basic Authentication API'); + +// Auto-discover and register services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/02-security/02-bearer-tokens/LoginService.php b/examples/02-security/02-bearer-tokens/LoginService.php new file mode 100644 index 0000000..141e75f --- /dev/null +++ b/examples/02-security/02-bearer-tokens/LoginService.php @@ -0,0 +1,50 @@ + ['password' => 'password123', 'role' => 'admin'], + 'user' => ['password' => 'userpass', 'role' => 'user'], + 'guest' => ['password' => 'guestpass', 'role' => 'guest'] + ]; + + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + #[RequestParam('username', 'string', false, null, 'Username')] + #[RequestParam('password', 'string', false, null, 'Password')] + public function login(string $username, string $password): array { + // Validate credentials + if (!isset(self::USERS[$username]) || self::USERS[$username]['password'] !== $password) { + throw new \InvalidArgumentException('Invalid credentials'); + } + + // Generate token + $userData = [ + 'username' => $username, + 'role' => self::USERS[$username]['role'], + 'login_time' => date('Y-m-d H:i:s') + ]; + + $token = TokenHelper::generateToken($userData); + + return [ + 'token' => $token, + 'user' => $userData, + 'expires_in' => 3600 + ]; + } + +} diff --git a/examples/02-security/02-bearer-tokens/ProfileService.php b/examples/02-security/02-bearer-tokens/ProfileService.php new file mode 100644 index 0000000..351d97b --- /dev/null +++ b/examples/02-security/02-bearer-tokens/ProfileService.php @@ -0,0 +1,56 @@ +getAuthHeader(); + $token = $authHeader->getCredentials(); + $tokenData = TokenHelper::validateToken($token); + $user = $tokenData['user']; + + return [ + 'user' => $user, + 'permissions' => $this->getUserPermissions($user['role']), + 'last_access' => date('Y-m-d H:i:s') + ]; + } + + public function isAuthorized(): bool { + $authHeader = $this->getAuthHeader(); + + if ($authHeader === null || $authHeader->getScheme() !== 'bearer') { + return false; + } + + $token = $authHeader->getCredentials(); + $tokenData = TokenHelper::validateToken($token); + + return $tokenData !== null; + } + + private function getUserPermissions(string $role): array { + switch ($role) { + case 'admin': + return ['read', 'write', 'delete', 'admin']; + case 'user': + return ['read', 'write']; + case 'guest': + return ['read']; + default: + return []; + } + } +} diff --git a/examples/02-security/02-bearer-tokens/README.md b/examples/02-security/02-bearer-tokens/README.md new file mode 100644 index 0000000..1a6ab51 --- /dev/null +++ b/examples/02-security/02-bearer-tokens/README.md @@ -0,0 +1,62 @@ +# Bearer Token Authentication + +Demonstrates Bearer token authentication with JWT-like tokens. + +## What This Example Demonstrates + +- Bearer token authentication +- Token validation and parsing +- Token expiration handling +- User context from tokens + +## Files + +- [`TokenAuthService.php`](TokenAuthService.php) - Original unified service +- [`LoginService.php`](LoginService.php) - Separate login service +- [`ProfileService.php`](ProfileService.php) - Separate profile service +- [`TokenHelper.php`](TokenHelper.php) - Token generation and validation utilities +- [`index.php`](index.php) - Main application entry point + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# Get a token first +curl -X POST "http://localhost:8080?service=auth&action=login" \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "password123"}' + +# Use the token (replace TOKEN with actual token from login) +curl -H "Authorization: Bearer TOKEN" "http://localhost:8080?service=auth&action=profile" + +# Invalid token +curl -H "Authorization: Bearer invalid_token" "http://localhost:8080?service=auth&action=profile" + +# Expired token simulation +curl -H "Authorization: Bearer expired_token" "http://localhost:8080?service=auth&action=profile" +``` + +## Code Explanation + +**Two approaches demonstrated:** + +1. **Separate Services** (Recommended): + - `LoginService` - Handles authentication, returns token + - `ProfileService` - Protected resource requiring Bearer token + - Clean separation of concerns + - Each service has single responsibility + +2. **Unified Service**: + - `TokenAuthService` - Handles both login and protected operations + - Uses different HTTP methods (POST for login, GET for profile) + +- Bearer token authentication using Authorization header +- Token validation and parsing utilities in TokenHelper +- `#[AllowAnonymous]` for login endpoint +- `#[RequiresAuth]` for protected endpoints +- Service is auto-discovered using `autoDiscoverServices()` diff --git a/examples/02-security/02-bearer-tokens/TokenAuthService.php b/examples/02-security/02-bearer-tokens/TokenAuthService.php new file mode 100644 index 0000000..e1f804f --- /dev/null +++ b/examples/02-security/02-bearer-tokens/TokenAuthService.php @@ -0,0 +1,153 @@ + ['password' => 'password123', 'role' => 'admin'], + 'user' => ['password' => 'userpass', 'role' => 'user'], + 'guest' => ['password' => 'guestpass', 'role' => 'guest'] + ]; + + private ?array $currentUser = null; + + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + #[RequestParam('operation', 'string', false, null, 'Operation: login')] + public function login(?string $operation = 'login'): array { + $inputs = $this->getInputs(); + + if (!($inputs instanceof Json)) { + throw new \InvalidArgumentException('JSON body required for login'); + } + + $username = $inputs->get('username'); + $password = $inputs->get('password'); + + if (!$username || !$password) { + throw new \InvalidArgumentException('Username and password required'); + } + + // Validate credentials + if (!isset(self::USERS[$username]) || self::USERS[$username]['password'] !== $password) { + throw new \InvalidArgumentException('Invalid credentials'); + } + + // Generate token + $userData = [ + 'username' => $username, + 'role' => self::USERS[$username]['role'], + 'login_time' => date('Y-m-d H:i:s') + ]; + + $token = TokenHelper::generateToken($userData); + + return [ + 'token' => $token, + 'user' => $userData, + 'expires_in' => 3600 + ]; + } + + #[GetMapping] + #[ResponseBody] + #[RequiresAuth] + #[RequestParam('operation', 'string', false, null, 'Operation: profile, refresh')] + public function handleAuthenticatedAction(?string $operation = 'profile'): array { + // Extract user from token + $authHeader = $this->getAuthHeader(); + $token = $authHeader->getCredentials(); + $tokenData = TokenHelper::validateToken($token); + $this->currentUser = $tokenData['user']; + + switch ($operation) { + case 'profile': + return $this->handleProfile(); + case 'refresh': + return $this->handleRefresh(); + default: + throw new \InvalidArgumentException('Unknown operation'); + } + } + + public function isAuthorized(): bool { + // All actions require Bearer token (login uses AllowAnonymous) + $authHeader = $this->getAuthHeader(); + + if ($authHeader === null) { + return false; + } + + $scheme = $authHeader->getScheme(); + $token = $authHeader->getCredentials(); + + if ($scheme !== 'bearer') { + return false; + } + + // Validate token + $tokenData = TokenHelper::validateToken($token); + + return $tokenData !== null; + } + + private function handleProfile(): array { + if (!$this->currentUser) { + throw new \RuntimeException('User context not available'); + } + + return [ + 'user' => $this->currentUser, + 'permissions' => $this->getUserPermissions($this->currentUser['role']), + 'last_access' => date('Y-m-d H:i:s') + ]; + } + + private function handleRefresh(): array { + if (!$this->currentUser) { + throw new \RuntimeException('User context not available'); + } + + // Generate new token with updated timestamp + $userData = $this->currentUser; + $userData['refresh_time'] = date('Y-m-d H:i:s'); + + $newToken = TokenHelper::generateToken($userData); + + return [ + 'token' => $newToken, + 'user' => $userData, + 'expires_in' => 3600 + ]; + } + + private function getUserPermissions(string $role): array { + switch ($role) { + case 'admin': + return ['read', 'write', 'delete', 'admin']; + case 'user': + return ['read', 'write']; + case 'guest': + return ['read']; + default: + return []; + } + } +} diff --git a/examples/02-security/02-bearer-tokens/TokenHelper.php b/examples/02-security/02-bearer-tokens/TokenHelper.php new file mode 100644 index 0000000..fecff73 --- /dev/null +++ b/examples/02-security/02-bearer-tokens/TokenHelper.php @@ -0,0 +1,69 @@ + $userData, + 'iat' => time(), + 'exp' => time() + self::TOKEN_EXPIRY + ]; + + $header = base64_encode(json_encode(['typ' => 'JWT', 'alg' => 'HS256'])); + $payload = base64_encode(json_encode($payload)); + $signature = base64_encode(hash_hmac('sha256', "$header.$payload", self::SECRET_KEY, true)); + + return "$header.$payload.$signature"; + } + + public static function validateToken(string $token): ?array { + $parts = explode('.', $token); + + if (count($parts) !== 3) { + return null; + } + + [$header, $payload, $signature] = $parts; + + // Verify signature + $expectedSignature = base64_encode(hash_hmac('sha256', "$header.$payload", self::SECRET_KEY, true)); + + if ($signature !== $expectedSignature) { + return null; + } + + // Decode payload + $payloadData = json_decode(base64_decode($payload), true); + + if (!$payloadData) { + return null; + } + + // Check expiration + if (isset($payloadData['exp']) && $payloadData['exp'] < time()) { + return null; // Token expired + } + + return $payloadData; + } + + public static function isTokenExpired(string $token): bool { + $parts = explode('.', $token); + + if (count($parts) !== 3) { + return true; + } + + $payloadData = json_decode(base64_decode($parts[1]), true); + + return isset($payloadData['exp']) && $payloadData['exp'] < time(); + } +} diff --git a/examples/02-security/02-bearer-tokens/index.php b/examples/02-security/02-bearer-tokens/index.php new file mode 100644 index 0000000..de2c248 --- /dev/null +++ b/examples/02-security/02-bearer-tokens/index.php @@ -0,0 +1,16 @@ +setVersion('1.0.0'); +$manager->setDescription('Bearer Token Authentication API'); + +// Auto-discover and register services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/02-security/04-role-based-access/AdminService.php b/examples/02-security/04-role-based-access/AdminService.php new file mode 100644 index 0000000..5d91a5f --- /dev/null +++ b/examples/02-security/04-role-based-access/AdminService.php @@ -0,0 +1,50 @@ + 'Welcome to admin panel', + 'user' => SecurityContext::getCurrentUser(), + 'admin_privileges' => [ + 'can_delete_users' => true, + 'can_modify_system' => true, + 'can_view_logs' => true + ], + 'system_stats' => [ + 'total_users' => 150, + 'active_sessions' => 23, + 'system_uptime' => '15 days' + ] + ]; + } + + public function isAuthorized(): bool { + $demoUser = new DemoUser( + id: 1, + name: 'Demo User', + roles: ['USER', 'ADMIN'], + authorities: ['USER_CREATE', 'USER_UPDATE', 'USER_DELETE', 'USER_READ', 'USER_MANAGE'] + ); + + SecurityContext::setCurrentUser($demoUser); + return true; + } +} diff --git a/examples/02-security/04-role-based-access/DemoUser.php b/examples/02-security/04-role-based-access/DemoUser.php new file mode 100644 index 0000000..af07032 --- /dev/null +++ b/examples/02-security/04-role-based-access/DemoUser.php @@ -0,0 +1,39 @@ +id; + } + + public function getName(): string { + return $this->name; + } + + public function getRoles(): array { + return $this->roles; + } + + public function getAuthorities(): array { + return $this->authorities; + } + + public function isActive(): bool { + return $this->active; + } +} diff --git a/examples/02-security/04-role-based-access/PublicService.php b/examples/02-security/04-role-based-access/PublicService.php new file mode 100644 index 0000000..e2b3229 --- /dev/null +++ b/examples/02-security/04-role-based-access/PublicService.php @@ -0,0 +1,36 @@ + 'This is public information - no authentication required', + 'timestamp' => date('Y-m-d H:i:s'), + 'server_info' => [ + 'php_version' => PHP_VERSION, + 'authenticated' => SecurityContext::isAuthenticated() + ] + ]; + } + + public function isAuthorized(): bool { + return true; // Public service, no auth needed + } +} diff --git a/examples/02-security/04-role-based-access/README.md b/examples/02-security/04-role-based-access/README.md new file mode 100644 index 0000000..cc49728 --- /dev/null +++ b/examples/02-security/04-role-based-access/README.md @@ -0,0 +1,68 @@ +# Role-Based Access Control (RBAC) + +Demonstrates role-based access control using SecurityContext and annotations. + +## What This Example Demonstrates + +- SecurityContext for user management +- Role-based authorization with `@PreAuthorize` +- Authority-based permissions +- Method-level security annotations + +## Files + +- [`PublicService.php`](PublicService.php) - Public information (no auth required) +- [`UserService.php`](UserService.php) - User profile service (requires authentication) +- [`AdminService.php`](AdminService.php) - Admin panel service (requires ADMIN role) +- [`UserManagerService.php`](UserManagerService.php) - User management (requires USER_MANAGE authority) +- [`DemoUser.php`](DemoUser.php) - Demo user implementation +- [`index.php`](index.php) - Main application entry point + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# Public endpoint (no auth required) +curl "http://localhost:8080?service=public" + +# User endpoint (requires authentication) +curl "http://localhost:8080?service=user" + +# Admin endpoint (requires ADMIN role) +curl "http://localhost:8080?service=admin" + +# User management endpoint (requires USER_MANAGE authority) +curl "http://localhost:8080?service=user-manager" +``` + +**Expected Responses:** + +Public access: +```json +{ + "message": "This is public information", + "http-code": 200 +} +``` + +With authentication: +```json +{ + "user": {...}, + "roles": ["USER", "ADMIN"], + "authorities": ["USER_CREATE", "USER_UPDATE"] +} +``` + +## Code Explanation + +- `isAuthorized()` handles authentication only - validates credentials and sets `SecurityContext::setCurrentUser()` +- `@PreAuthorize` annotations handle method-level authorization based on roles/authorities +- `@AllowAnonymous` bypasses authentication requirements for public endpoints +- `@RequiresAuth` ensures user is authenticated before method execution +- Role and authority checks are evaluated at runtime using security expressions diff --git a/examples/02-security/04-role-based-access/UserManagerService.php b/examples/02-security/04-role-based-access/UserManagerService.php new file mode 100644 index 0000000..028e00c --- /dev/null +++ b/examples/02-security/04-role-based-access/UserManagerService.php @@ -0,0 +1,50 @@ + 'User management operation completed', + 'user' => SecurityContext::getCurrentUser(), + 'available_operations' => [ + 'create_user' => SecurityContext::hasAuthority('USER_CREATE'), + 'update_user' => SecurityContext::hasAuthority('USER_UPDATE'), + 'delete_user' => SecurityContext::hasAuthority('USER_DELETE'), + 'view_users' => SecurityContext::hasAuthority('USER_READ') + ], + 'managed_users' => [ + ['id' => 1, 'name' => 'John Doe', 'status' => 'active'], + ['id' => 2, 'name' => 'Jane Smith', 'status' => 'inactive'] + ] + ]; + } + + public function isAuthorized(): bool { + $demoUser = new DemoUser( + id: 1, + name: 'Demo User', + roles: ['USER', 'ADMIN'], + authorities: ['USER_CREATE', 'USER_UPDATE', 'USER_DELETE', 'USER_READ', 'USER_MANAGE'] + ); + + SecurityContext::setCurrentUser($demoUser); + return true; + } +} diff --git a/examples/02-security/04-role-based-access/UserService.php b/examples/02-security/04-role-based-access/UserService.php new file mode 100644 index 0000000..e1ccb49 --- /dev/null +++ b/examples/02-security/04-role-based-access/UserService.php @@ -0,0 +1,43 @@ + SecurityContext::getCurrentUser(), + 'roles' => SecurityContext::getRoles(), + 'authorities' => SecurityContext::getAuthorities(), + 'is_authenticated' => SecurityContext::isAuthenticated(), + 'access_time' => date('Y-m-d H:i:s') + ]; + } + + public function isAuthorized(): bool { + $demoUser = new DemoUser( + id: 1, + name: 'Demo User', + roles: ['USER', 'ADMIN'], + authorities: ['USER_CREATE', 'USER_UPDATE', 'USER_DELETE', 'USER_READ', 'USER_MANAGE'] + ); + + SecurityContext::setCurrentUser($demoUser); + return true; + } +} diff --git a/examples/02-security/04-role-based-access/index.php b/examples/02-security/04-role-based-access/index.php new file mode 100644 index 0000000..edc6990 --- /dev/null +++ b/examples/02-security/04-role-based-access/index.php @@ -0,0 +1,22 @@ +setVersion('1.0.0'); +$manager->setDescription('Role-Based Access Control API - Multiple Services'); + +// Register all RBAC services +$manager->addService(new PublicService()); +$manager->addService(new UserService()); +$manager->addService(new AdminService()); +$manager->addService(new UserManagerService()); + +// Process the incoming request +$manager->process(); diff --git a/examples/02-security/README.md b/examples/02-security/README.md new file mode 100644 index 0000000..1b8181a --- /dev/null +++ b/examples/02-security/README.md @@ -0,0 +1,24 @@ +# Security Examples + +This folder demonstrates authentication and authorization patterns in WebFiori HTTP. + +## Examples + +1. **[01-basic-auth](01-basic-auth/)** - HTTP Basic authentication +2. **[02-bearer-tokens](02-bearer-tokens/)** - JWT/Bearer token authentication +4. **[04-role-based-access](04-role-based-access/)** - Role-based access control (RBAC) +5. **05-method-security** - Per-method authorization + +## Security Concepts + +- Authentication vs Authorization +- HTTP authentication schemes +- Token-based authentication +- Role and permission systems +- Security context management + +## Prerequisites + +- Understanding of HTTP authentication +- Basic knowledge of JWT tokens +- Familiarity with RBAC concepts diff --git a/examples/04-advanced/01-object-mapping/GetObjectMappingService.php b/examples/04-advanced/01-object-mapping/GetObjectMappingService.php new file mode 100644 index 0000000..454918a --- /dev/null +++ b/examples/04-advanced/01-object-mapping/GetObjectMappingService.php @@ -0,0 +1,30 @@ +getObject(User::class); + + return [ + 'message' => 'User created with getObject mapping', + 'user' => $user->toArray(), + 'method' => 'getobject_mapping' + ]; + } +} diff --git a/examples/04-advanced/01-object-mapping/ManualMappingService.php b/examples/04-advanced/01-object-mapping/ManualMappingService.php new file mode 100644 index 0000000..2e54ac5 --- /dev/null +++ b/examples/04-advanced/01-object-mapping/ManualMappingService.php @@ -0,0 +1,37 @@ +getInputs(); + $user = new User(); + + if ($inputs instanceof \WebFiori\Json\Json) { + if ($inputs->hasKey('name')) $user->setName($inputs->get('name')); + if ($inputs->hasKey('email')) $user->setEmail($inputs->get('email')); + if ($inputs->hasKey('age')) $user->setAge($inputs->get('age')); + } + + return [ + 'message' => 'User created with manual mapping', + 'user' => $user->toArray(), + 'method' => 'manual_mapping' + ]; + } +} diff --git a/examples/04-advanced/01-object-mapping/MapEntityCustomMappingService.php b/examples/04-advanced/01-object-mapping/MapEntityCustomMappingService.php new file mode 100644 index 0000000..b671623 --- /dev/null +++ b/examples/04-advanced/01-object-mapping/MapEntityCustomMappingService.php @@ -0,0 +1,30 @@ + 'setFullName', 'email-address' => 'setEmailAddress', 'user-age' => 'setUserAge'])] + public function create(User $user): array { + return [ + 'message' => 'User created with MapEntity + custom setters', + 'user' => $user->toArray(), + 'method' => 'mapentity_custom' + ]; + } +} diff --git a/examples/04-advanced/01-object-mapping/MapEntityMappingService.php b/examples/04-advanced/01-object-mapping/MapEntityMappingService.php new file mode 100644 index 0000000..aa1a5b5 --- /dev/null +++ b/examples/04-advanced/01-object-mapping/MapEntityMappingService.php @@ -0,0 +1,30 @@ + 'User created with MapEntity attribute', + 'user' => $user->toArray(), + 'method' => 'mapentity_basic' + ]; + } +} diff --git a/examples/04-advanced/01-object-mapping/README.md b/examples/04-advanced/01-object-mapping/README.md new file mode 100644 index 0000000..7452e0b --- /dev/null +++ b/examples/04-advanced/01-object-mapping/README.md @@ -0,0 +1,180 @@ +# Object Mapping + +Demonstrates all available approaches for mapping HTTP request parameters to PHP objects in WebFiori HTTP. + +## What This Example Demonstrates + +- **5 Different Object Mapping Approaches** +- Parameter validation and type safety +- Custom setter method mapping +- Clean separation of data and logic +- Modern attribute-based mapping (NEW) + +## Files + +- [`User.php`](User.php) - Data model class with validation +- [`TraditionalMappingService.php`](TraditionalMappingService.php) - Traditional parameter mapping +- [`ManualMappingService.php`](ManualMappingService.php) - Manual object mapping +- [`GetObjectMappingService.php`](GetObjectMappingService.php) - getObject() mapping +- [`MapEntityMappingService.php`](MapEntityMappingService.php) - MapEntity attribute mapping +- [`MapEntityCustomMappingService.php`](MapEntityCustomMappingService.php) - MapEntity with custom setters +- [`index.php`](index.php) - Main application entry point + +## How to Run + +```bash +php -S localhost:8080 +``` + + +## Object Mapping Approaches + +### 1. Traditional Parameter Mapping +**Method signature with individual parameters** +```php +#[RequestParam('name', 'string', false)] +#[RequestParam('email', 'string', false)] +#[RequestParam('age', 'int', false)] +public function create(string $name, string $email, int $age): array +``` + +### 2. Manual Object Mapping +**Manual parameter extraction and object creation** +```php +public function create(): array { + $inputs = $this->getInputs(); + $user = new User(); + if ($inputs->hasKey('name')) $user->setName($inputs->get('name')); + // ... manual mapping +} +``` + +### 3. getObject() Mapping +**Framework-assisted object mapping** +```php +public function create(): array { + $user = $this->getObject(User::class); + // Object automatically mapped from request +} +``` + +### 4. MapEntity Attribute - Basic +**Clean attribute-based mapping (NEW)** +```php +#[MapEntity(User::class)] +public function create(User $user): array { + // $user automatically mapped and injected +} +``` + +### 5. MapEntity Attribute - Custom Setters +**Flexible parameter naming with custom setters (NEW)** +```php +#[MapEntity(User::class, setters: ['full-name' => 'setFullName', 'email-address' => 'setEmailAddress'])] +public function create(User $user): array { + // Custom parameter mapping handled automatically +} +``` + +## Testing All Approaches + +```bash +# 1. Traditional Parameters +curl -X POST "http://localhost:8080?service=traditional" \ + -H "Content-Type: application/json" \ + -d '{"name": "John Doe", "email": "john@example.com", "age": 30}' + +# 2. Manual Mapping +curl -X POST "http://localhost:8080?service=manual" \ + -H "Content-Type: application/json" \ + -d '{"name": "John Doe", "email": "john@example.com", "age": 30}' + +# 3. getObject() Mapping +curl -X POST "http://localhost:8080?service=getobject" \ + -H "Content-Type: application/json" \ + -d '{"name": "John Doe", "email": "john@example.com", "age": 30}' + +# 4. MapEntity Basic +curl -X POST "http://localhost:8080?service=mapentity" \ + -H "Content-Type: application/json" \ + -d '{"name": "John Doe", "email": "john@example.com", "age": 30}' + +# 5. MapEntity Custom Setters +curl -X POST "http://localhost:8080?service=mapentity-custom" \ + -H "Content-Type: application/json" \ + -d '{"full-name": "Jane Smith", "email-address": "jane@example.com", "user-age": 25}' +``` + +## Comparison of Approaches + +| Approach | Pros | Cons | Best For | +|----------|------|------|----------| +| **Traditional Parameters** | ✅ Explicit, IDE support | ❌ Verbose for many params | Simple endpoints | +| **Manual Mapping** | ✅ Full control, flexible | ❌ Boilerplate code | Complex validation | +| **getObject() Mapping** | ✅ Automatic, less code | ❌ Silent error handling | Standard mapping | +| **MapEntity Basic** | ✅ Clean, type-safe | ❌ Less flexible | Modern development | +| **MapEntity Custom** | ✅ Flexible + clean | ❌ Setup complexity | Legacy API integration | + +## Expected Results + +All approaches produce the same result structure: +```json +{ + "message": "User created with [approach name]", + "user": { + "name": "John Doe", + "email": "john@example.com", + "age": 30, + "phone": null, + "address": null + }, + "method": "[approach_identifier]" +} +``` + +## Code Explanation + +### Evolution of Object Mapping + +**1. Traditional Approach** - Explicit parameter handling +```php +public function create(string $name, string $email, int $age): array { + $user = new User(); + $user->setName($name); + $user->setEmail($email); + $user->setAge($age); +} +``` + +**2. Manual Mapping** - Direct input processing +```php +public function create(): array { + $inputs = $this->getInputs(); + $user = new User(); + if ($inputs->hasKey('name')) $user->setName($inputs->get('name')); +} +``` + +**3. Framework Mapping** - Automated object creation +```php +public function create(): array { + $user = $this->getObject(User::class); + // Framework handles mapping automatically +} +``` + +**4. Modern Attribute Mapping** - Clean and type-safe +```php +#[MapEntity(User::class)] +public function create(User $user): array { + // $user is automatically mapped and validated +} +``` + + +### When to Use Each Approach + +- **Traditional**: Simple endpoints with few parameters +- **Manual**: Complex validation or transformation logic +- **getObject()**: Standard mapping with framework control +- **MapEntity**: Modern development with type safety (RECOMMENDED) diff --git a/examples/04-advanced/01-object-mapping/TraditionalMappingService.php b/examples/04-advanced/01-object-mapping/TraditionalMappingService.php new file mode 100644 index 0000000..ff219a3 --- /dev/null +++ b/examples/04-advanced/01-object-mapping/TraditionalMappingService.php @@ -0,0 +1,37 @@ +setName($name); + $user->setEmail($email); + $user->setAge($age); + + return [ + 'message' => 'User created with traditional parameters', + 'user' => $user->toArray(), + 'method' => 'traditional_parameters' + ]; + } +} diff --git a/examples/04-advanced/01-object-mapping/User.php b/examples/04-advanced/01-object-mapping/User.php new file mode 100644 index 0000000..71e83a4 --- /dev/null +++ b/examples/04-advanced/01-object-mapping/User.php @@ -0,0 +1,109 @@ +name = trim($name); + } + + public function getName(): ?string { + return $this->name; + } + + public function setEmail(string $email): void { + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Invalid email format'); + } + $this->email = strtolower($email); + } + + public function getEmail(): ?string { + return $this->email; + } + + public function setAge(int $age): void { + if ($age < 0 || $age > 150) { + throw new \InvalidArgumentException('Age must be between 0 and 150'); + } + $this->age = $age; + } + + public function getAge(): ?int { + return $this->age; + } + + public function setPhone(?string $phone): void { + if ($phone !== null) { + $this->phone = preg_replace('/[^0-9+\-\s]/', '', $phone); + } + } + + public function getPhone(): ?string { + return $this->phone; + } + + public function setAddress(?string $address): void { + if ($address !== null) { + $this->address = trim($address); + } + } + + public function getAddress(): ?string { + return $this->address; + } + + // Custom setters for alternative parameter names + public function setFullName(?string $name): void { + if ($name !== null) { + $this->setName($name); + } + } + + public function setEmailAddress(?string $email): void { + if ($email !== null) { + $this->setEmail($email); + } + } + + public function setUserAge(?int $age): void { + if ($age !== null) { + $this->setAge($age); + } + } + + public function validate(): array { + $errors = []; + + if (empty($this->name)) { + $errors[] = 'Name is required'; + } + + if (empty($this->email)) { + $errors[] = 'Email is required'; + } + + if ($this->age === null) { + $errors[] = 'Age is required'; + } + + return $errors; + } + + public function toArray(): array { + return [ + 'name' => $this->name, + 'email' => $this->email, + 'age' => $this->age, + 'phone' => $this->phone, + 'address' => $this->address + ]; + } +} diff --git a/examples/04-advanced/01-object-mapping/index.php b/examples/04-advanced/01-object-mapping/index.php new file mode 100644 index 0000000..8d6b168 --- /dev/null +++ b/examples/04-advanced/01-object-mapping/index.php @@ -0,0 +1,16 @@ +setVersion('1.0.0'); +$manager->setDescription('Complete Object Mapping API Examples'); + +// Auto-discover and register all services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/04-advanced/03-manual-openapi/OpenAPIService.php b/examples/04-advanced/03-manual-openapi/OpenAPIService.php new file mode 100644 index 0000000..3c644c8 --- /dev/null +++ b/examples/04-advanced/03-manual-openapi/OpenAPIService.php @@ -0,0 +1,23 @@ +setRequestMethods([RequestMethod::GET]); + } + + public function processRequest() { + $openApiObj = $this->getManager()->toOpenAPI(); + $info = $openApiObj->getInfo(); + $info->setTermsOfService('https://example.com/terms'); + $this->send('application/json', $openApiObj->toJSON(), 200); + } +} diff --git a/examples/04-advanced/03-manual-openapi/ProductService.php b/examples/04-advanced/03-manual-openapi/ProductService.php new file mode 100644 index 0000000..1962b9c --- /dev/null +++ b/examples/04-advanced/03-manual-openapi/ProductService.php @@ -0,0 +1,53 @@ + 1, 'name' => 'Product A', 'category' => $category ?? 'Electronics'], + ['id' => 2, 'name' => 'Product B', 'category' => $category ?? 'Electronics'] + ]; + } + + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + #[Param('name', ParamType::STRING, 'Product name')] + #[Param('price', ParamType::DOUBLE, 'Product price', min: 0)] + public function createProduct(string $name, float $price): array { + return ['id' => 3, 'name' => $name, 'price' => $price]; + } + + #[PutMapping] + #[ResponseBody] + #[AllowAnonymous] + #[Param('id', ParamType::INT, 'Product ID')] + #[Param('name', ParamType::STRING, 'Product name')] + public function updateProduct(int $id, string $name): array { + return ['id' => $id, 'name' => $name]; + } + + #[DeleteMapping] + #[ResponseBody] + #[AllowAnonymous] + #[Param('id', ParamType::INT, 'Product ID')] + public function deleteProduct(int $id): array { + return ['deleted' => $id]; + } +} diff --git a/examples/04-advanced/03-manual-openapi/README.md b/examples/04-advanced/03-manual-openapi/README.md new file mode 100644 index 0000000..200d000 --- /dev/null +++ b/examples/04-advanced/03-manual-openapi/README.md @@ -0,0 +1,74 @@ +# Using OpenAPIObj with WebServicesManager + +This example demonstrates how to use the `OpenAPIObj` returned by `WebServicesManager::toOpenAPI()` to generate and serve OpenAPI 3.1.0 documentation for your REST API. + +## Files + +- `index.php` - Main entry point with WebServicesManager setup +- `UserService.php` - Sample user management service +- `ProductService.php` - Sample product catalog service +- `OpenAPIService.php` - Service that returns OpenAPI specification + +## What This Example Shows + +- Using `WebServicesManager::toOpenAPI()` to generate OpenAPI specification +- Creating a dedicated service endpoint to serve the OpenAPI documentation +- Accessing and customizing the `OpenAPIObj` and its `InfoObj` +- Automatic documentation generation from registered services + +## Running the Example + +```bash +# Start PHP built-in server +php -S localhost:8000 + +# Access the OpenAPI documentation +curl http://localhost:8000?service=openapi + +# Or visit in browser +http://localhost:8000?service=openapi +``` + +## OpenAPIObj Structure + +```php +$openApiObj = $manager->toOpenAPI(); + +// Access components +$info = $openApiObj->getInfo(); // InfoObj with API metadata +$paths = $openApiObj->getPaths(); // PathsObj with all endpoints +$version = $openApiObj->getOpenapi(); // OpenAPI spec version (3.1.0) +``` + +## Using the Output + +### With Swagger UI + +1. Copy the JSON output +2. Visit https://editor.swagger.io/ +3. Paste the JSON +4. View interactive documentation + +### With Postman + +1. Save output to `openapi.json` +2. In Postman: Import → Upload Files +3. Select the file +4. All endpoints are imported + +## Customizing the Output + +```php +// In OpenAPIService.php +$openApiObj = $this->getManager()->toOpenAPI(); + +// Customize info +$info = $openApiObj->getInfo(); +$info->setTermsOfService('https://example.com/terms'); +$info->setSummary('My API Summary'); +``` + +## Related Examples + +- **02-openapi-docs** - Basic OpenAPI generation setup +- **01-object-mapping** - Request parameter mapping diff --git a/examples/04-advanced/03-manual-openapi/UserService.php b/examples/04-advanced/03-manual-openapi/UserService.php new file mode 100644 index 0000000..8edeb75 --- /dev/null +++ b/examples/04-advanced/03-manual-openapi/UserService.php @@ -0,0 +1,39 @@ + $id ?? 1, + 'name' => 'John Doe', + 'email' => 'john@example.com' + ]; + } + + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + #[Param('name', ParamType::STRING, 'User full name', minLength: 2, maxLength: 100)] + #[Param('email', ParamType::EMAIL, 'User email address')] + public function createUser(string $name, string $email): array { + return [ + 'id' => 2, + 'name' => $name, + 'email' => $email + ]; + } +} diff --git a/examples/04-advanced/03-manual-openapi/index.php b/examples/04-advanced/03-manual-openapi/index.php new file mode 100644 index 0000000..1594910 --- /dev/null +++ b/examples/04-advanced/03-manual-openapi/index.php @@ -0,0 +1,16 @@ +setVersion('1.0.0'); +$manager->setDescription('OpenAPI Documentation Example'); + +// Auto-discover and register all services +$manager->autoDiscoverServices(); + +// Process the incoming request +$manager->process(); diff --git a/examples/04-advanced/README.md b/examples/04-advanced/README.md new file mode 100644 index 0000000..4e46691 --- /dev/null +++ b/examples/04-advanced/README.md @@ -0,0 +1,26 @@ +# Advanced Patterns Examples + +This folder demonstrates advanced WebFiori HTTP patterns and techniques. + +## Examples + +1. **[01-object-mapping](01-object-mapping/)** - Request to object mapping with MapEntity attribute +2. **02-custom-filters** - Custom parameter validation +3. **03-exception-handling** - Global exception handling +4. **04-middleware-pattern** - Request/response interceptors +5. **05-streaming-responses** - Large data streaming +3. **[03-manual-openapi](03-manual-openapi/)** - Using OpenAPIObj to serve API documentation + +## Advanced Concepts + +- Object-oriented request handling with MapEntity attribute +- Custom validation and filtering +- Structured exception handling +- Middleware and interceptors +- Performance optimization techniques + +## Prerequisites + +- Understanding of PHP 8+ features (attributes, typed properties) +- Familiarity with basic WebFiori HTTP concepts +- Knowledge of object-oriented programming patterns diff --git a/examples/AnnotatedService.php b/examples/AnnotatedService.php deleted file mode 100644 index 542d83b..0000000 --- a/examples/AnnotatedService.php +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 4103d18..0000000 --- a/examples/AuthTestService.php +++ /dev/null @@ -1,19 +0,0 @@ - 'You have super admin access!']; - } -} diff --git a/examples/AuthenticatedController.php b/examples/AuthenticatedController.php deleted file mode 100644 index 93562a9..0000000 --- a/examples/AuthenticatedController.php +++ /dev/null @@ -1,142 +0,0 @@ -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 deleted file mode 100644 index 16f4e4a..0000000 --- a/examples/CompleteApiDemo.php +++ /dev/null @@ -1,124 +0,0 @@ -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/GetRandomService.php b/examples/GetRandomService.php deleted file mode 100644 index 7e1cc8d..0000000 --- a/examples/GetRandomService.php +++ /dev/null @@ -1,47 +0,0 @@ -setRequestMethods([RequestMethod::GET, RequestMethod::POST]); - $this->setDescription('Returns a random integer. If no range is specified, the method will return a number between 0 and getrandmax().'); - - $this->addParameters([ - 'min' => [ - ParamOption::TYPE => ParamType::INT, - ParamOption::OPTIONAL => true, - ParamOption::DESCRIPTION => 'Minimum value for the random number.' - ], - 'max' => [ - ParamOption::TYPE => ParamType::INT, - ParamOption::OPTIONAL => true, - ParamOption::DESCRIPTION => 'Maximum value for the random number.' - ] - ]); - } - - public function isAuthorized(): bool { - return true; - } - - public function processRequest() { - $max = $this->getParamVal('max'); - $min = $this->getParamVal('min'); - - if ($max !== null && $min !== null) { - $random = rand($min, $max); - } else { - $random = rand(); - } - - $this->sendResponse('Random number generated', 'success', 200, [ - 'number' => $random - ]); - } -} diff --git a/examples/HelloWithAuthService.php b/examples/HelloWithAuthService.php deleted file mode 100644 index 2585571..0000000 --- a/examples/HelloWithAuthService.php +++ /dev/null @@ -1,51 +0,0 @@ -setRequestMethods([RequestMethod::GET]); - - $this->addParameters([ - 'my-name' => [ - ParamOption::TYPE => ParamType::STRING, - ParamOption::OPTIONAL => true - ] - ]); - } - public function isAuthorized(): bool { - //Change default response message to custom one - ResponseMessage::set('401', 'Not authorized to use this API.'); - - $authHeader = $this->getAuthHeader(); - - if ($authHeader === null) { - return false; - } - - $scheme = $authHeader->getScheme(); - $credentials = $authHeader->getCredentials(); - - if ($scheme != 'bearer') { - return false; - } - - return $credentials == 'abc123trX'; - } - - public function processRequest() { - $name = $this->getParamVal('my-name'); - - if ($name !== null) { - $this->sendResponse("Hello '$name'."); - } - $this->sendResponse('Hello World!'); - } -} diff --git a/examples/HelloWorldService.php b/examples/HelloWorldService.php deleted file mode 100644 index 23dbd9c..0000000 --- a/examples/HelloWorldService.php +++ /dev/null @@ -1,36 +0,0 @@ -setRequestMethods([RequestMethod::GET]); - $this->setDescription('Returns a greeting message.'); - - $this->addParameters([ - 'my-name' => [ - ParamOption::TYPE => ParamType::STRING, - ParamOption::OPTIONAL => true, - ParamOption::DESCRIPTION => 'Your name to include in the greeting.' - ] - ]); - } - public function isAuthorized(): bool { - return true; - } - - public function processRequest() { - $name = $this->getParamVal('my-name'); - - if ($name !== null) { - $this->sendResponse("Hello '$name'."); - } else { - $this->sendResponse('Hello World!'); - } - } -} diff --git a/examples/ProductController.php b/examples/ProductController.php deleted file mode 100644 index 760edaf..0000000 --- a/examples/ProductController.php +++ /dev/null @@ -1,91 +0,0 @@ -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/README.md b/examples/README.md index 1b22b36..1def9f4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,337 +1,30 @@ # WebFiori HTTP Examples -This folder contains practical examples demonstrating how to use the WebFiori HTTP library to create RESTful web services. The examples showcase different features including basic services, parameter handling, and authentication. +This directory contains comprehensive code samples demonstrating the WebFiori HTTP library features, organized from basic to advanced usage patterns. -## Table of Contents +## Structure -- [Prerequisites](#prerequisites) -- [Setup](#setup) -- [Available Services](#available-services) - - [1. Hello World Service](#1-hello-world-service-helloworldservicephp) - - [2. Random Number Service](#2-random-number-service-getrandomservicephp) - - [3. Hello with Authentication Service](#3-hello-with-authentication-service-hellowithAuthservicephp) -- [Main Application](#main-application-indexphp) -- [Loader Configuration](#loader-configuration-loaderphp) -- [Key Concepts Demonstrated](#key-concepts-demonstrated) -- [Testing All Services](#testing-all-services) -- [Notes](#notes) +- **[00-basic/](00-basic/)** - Foundation concepts and simple services +- **[01-core/](01-core/)** - Core features like validation, error handling +- **[02-security/](02-security/)** - Authentication and authorization patterns +- **[03-annotations/](03-annotations/)** - Modern annotation-based development +- **[04-advanced/](04-advanced/)** - Advanced patterns and techniques +- **05-applications/** - Real-world application examples -## Prerequisites +## Quick Start -- PHP 8.1 or higher -- Composer installed -- WebFiori HTTP library dependencies - -## Setup - -1. **Install dependencies** (run from the project root directory): +1. Install dependencies: ```bash composer install ``` -2. **Navigate to the examples directory**: +2. Navigate to any example folder and run: ```bash - cd examples + php -S localhost:8080 ``` -3. **Start the PHP development server**: - ```bash - php -S localhost:8989 - ``` - -## Available Services - -### 1. Hello World Service (`HelloWorldService.php`) - -A basic service that demonstrates simple parameter handling. - -**Service Name**: `hello` -**HTTP Methods**: GET -**Parameters**: -- `my-name` (optional, string): Name to include in greeting - -**Code Example**: -```php -setRequestMethods([RequestMethod::GET]); - - $this->addParameters([ - 'my-name' => [ - ParamOption::TYPE => ParamType::STRING, - ParamOption::OPTIONAL => true - ] - ]); - } - public function isAuthorized() { - } - - public function processRequest() { - $name = $this->getParamVal('my-name'); - - if ($name !== null) { - $this->sendResponse("Hello '$name'."); - } - $this->sendResponse('Hello World!'); - } -} -``` - -**Test URLs**: -```bash -# Basic hello -curl "http://localhost:8989?service=hello" -# Response: {"message":"Hello World!","http-code":200} - -# Hello with name -curl "http://localhost:8989?service=hello&my-name=ibrahim" -# Response: {"message":"Hello 'ibrahim'.","http-code":200} -``` - -### 2. Random Number Service (`GetRandomService.php`) - -Demonstrates parameter validation and processing with optional integer parameters. - -**Service Name**: `get-random-number` -**HTTP Methods**: GET, POST -**Parameters**: -- `min` (optional, integer): Minimum value for random number -- `max` (optional, integer): Maximum value for random number - -**Code Example**: -```php -setRequestMethods([ - RequestMethod::GET, - RequestMethod::POST - ]); - - $this->addParameters([ - 'min' => [ - ParamOption::TYPE => ParamType::INT, - ParamOption::OPTIONAL => true - ], - 'max' => [ - ParamOption::TYPE => ParamType::INT, - ParamOption::OPTIONAL => true - ] - ]); - } - - public function isAuthorized() { -// $authHeader = $this->getAuthHeader(); -// -// if ($authHeader === null) { -// return false; -// } -// -// $scheme = $authHeader->getScheme(); -// $credentials = $authHeader->getCredentials(); - - //Verify credentials based on auth scheme (e.g. 'Basic', 'Barear' - } - - public function processRequest() { - $max = $this->getParamVal('max'); - $min = $this->getParamVal('min'); - - if ($max !== null && $min !== null) { - $random = rand($min, $max); - } else { - $random = rand(); - } - $this->sendResponse($random); - } -} -``` - -**Test URLs**: -```bash -# Random number without bounds -curl "http://localhost:8989?service=get-random-number" -# Response: {"message":"1255598581","http-code":200} - -# Random number between 1 and 10 -curl "http://localhost:8989?service=get-random-number&min=1&max=10" -# Response: {"message":"7","http-code":200} - -# Random number between -4 and 0 -curl "http://localhost:8989?service=get-random-number&min=-4&max=0" -# Response: {"message":"-1","http-code":200} - -# Invalid parameter type (demonstrates validation) -curl "http://localhost:8989?service=get-random-number&min=-4&max=Super" -# Response: {"message":"The following parameter(s) has invalid values: 'max'.","type":"error","http-code":404,"more-info":{"invalid":["max"]}} -``` - -### 3. Hello with Authentication Service (`HelloWithAuthService.php`) - -Demonstrates Bearer token authentication implementation. - -**Service Name**: `hello-with-auth` -**HTTP Methods**: GET -**Authentication**: Bearer token required (`abc123trX`) -**Parameters**: -- `my-name` (optional, string): Name to include in greeting - -**Code Example**: -```php -setRequestMethods([RequestMethod::GET]); - - $this->addParameters([ - 'my-name' => [ - ParamOption::TYPE => ParamType::STRING, - ParamOption::OPTIONAL => true - ] - ]); - } - public function isAuthorized() { - //Change default response message to custom one - ResponseMessage::set('401', 'Not authorized to use this API.'); - - $authHeader = $this->getAuthHeader(); - - if ($authHeader === null) { - return false; - } - - $scheme = $authHeader->getScheme(); - $credentials = $authHeader->getCredentials(); - - if ($scheme != 'bearer') { - return false; - } - - return $credentials == 'abc123trX'; - } - - public function processRequest() { - $name = $this->getParamVal('my-name'); - - if ($name !== null) { - $this->sendResponse("Hello '$name'."); - } - $this->sendResponse('Hello World!'); - } -} -``` - -**Test URLs**: -```bash -# Without authorization (will fail) -curl "http://localhost:8989?service=hello-with-auth&my-name=ibrahim" -# Response: {"message":"Not authorized to use this API.","type":"error","http-code":401} - -# With correct Bearer token -curl -H "Authorization: Bearer abc123trX" "http://localhost:8989?service=hello-with-auth&my-name=ibrahim" -# Response: {"message":"Hello 'ibrahim'.","http-code":200} -``` - -## Main Application (`index.php`) - -The main entry point that registers all services with the WebServicesManager: - -```php -addService(new HelloWorldService()); -$manager->addService(new GetRandomService()); -$manager->addService(new HelloWithAuthService()); -$manager->process(); -``` - -## Loader Configuration (`loader.php`) - -Sets up error reporting and autoloading: - -```php -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 deleted file mode 100644 index 537e386..0000000 --- a/examples/index.php +++ /dev/null @@ -1,35 +0,0 @@ - 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 deleted file mode 100644 index a83718c..0000000 --- a/examples/loader.php +++ /dev/null @@ -1,7 +0,0 @@ -setDefault('default_value'); + $filter->addRequestParameter($param); + + $filter->filterGET(); + $inputs = $filter->getInputs(); + $this->assertEquals('default_value', $inputs['test']); + } + + public function testEmptyStringNotAllowed() { + $filter = new APIFilter(); + $param = new RequestParameter('test', 'string'); + $param->setIsEmptyStringAllowed(false); + $filter->addRequestParameter($param); + + $_GET['test'] = ''; + $filter->filterGET(); + $inputs = $filter->getInputs(); + $this->assertEquals(APIFilter::INVALID, $inputs['test']); + unset($_GET['test']); + } + + public function testInvalidValueWithDefault() { + $filter = new APIFilter(); + $param = new RequestParameter('age', 'integer', true); + $param->setDefault(25); + $filter->addRequestParameter($param); + + $_GET['age'] = 'invalid'; + $filter->filterGET(); + $inputs = $filter->getInputs(); + $this->assertEquals(25, $inputs['age']); + unset($_GET['age']); + } + + public function testArrayParameter() { + $filter = new APIFilter(); + $param = new RequestParameter('items', 'array'); + $filter->addRequestParameter($param); + + $_GET['items'] = ['a', 'b', 'c']; + $filter->filterGET(); + $inputs = $filter->getInputs(); + $this->assertIsArray($inputs['items']); + $this->assertCount(3, $inputs['items']); + unset($_GET['items']); + } + + public function testDoubleWithMinMax() { + $filter = new APIFilter(); + $param = new RequestParameter('price', 'double'); + $param->setMinValue(0); + $param->setMaxValue(100); + $filter->addRequestParameter($param); + + $_GET['price'] = '50.5'; + $filter->filterGET(); + $inputs = $filter->getInputs(); + $this->assertNotEquals(APIFilter::INVALID, $inputs['price']); + unset($_GET['price']); + } + + public function testArrayStringParsing() { + $filter = new APIFilter(); + $param = new RequestParameter('values', 'array'); + $filter->addRequestParameter($param); + + $_GET['values'] = '[true,false,null,123,45.6]'; + $filter->filterGET(); + $inputs = $filter->getInputs(); + + if (is_array($inputs['values'])) { + $this->assertGreaterThan(0, count($inputs['values'])); + } + unset($_GET['values']); + } + + public function testBooleanParameter() { + $filter = new APIFilter(); + $param = new RequestParameter('active', 'boolean'); + $filter->addRequestParameter($param); + + $_GET['active'] = 'true'; + $filter->filterGET(); + $inputs = $filter->getInputs(); + $this->assertTrue($inputs['active'] === true || $inputs['active'] === 1); + unset($_GET['active']); + } + + public function testOptionalParameterNotProvided() { + $filter = new APIFilter(); + $param = new RequestParameter('optional', 'string', true); + $filter->addRequestParameter($param); + + $filter->filterGET(); + $inputs = $filter->getInputs(); + $this->assertArrayHasKey('optional', $inputs); + } + + public function testClearInputs() { + $filter = new APIFilter(); + $param = new RequestParameter('test', 'string'); + $filter->addRequestParameter($param); + + $_GET['test'] = 'value'; + $filter->filterGET(); + $this->assertNotEmpty($filter->getInputs()); + + $filter->clearInputs(); + $this->assertEmpty($filter->getInputs()); + unset($_GET['test']); + } } diff --git a/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php b/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php index 9345a3f..ad71e85 100644 --- a/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php +++ b/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php @@ -48,19 +48,19 @@ public function testMethodLevelAuthorization() { // Test admin method without admin role $_GET['action'] = 'admin'; - SecurityContext::setRoles(['USER']); + SecurityContext::setCurrentUser(new TestUser(1, ['USER'], [], true)); $this->assertFalse($service->checkMethodAuthorization()); // Test admin method with admin role - SecurityContext::setRoles(['ADMIN']); + SecurityContext::setCurrentUser(new TestUser(1, ['ADMIN'], [], true)); $this->assertTrue($service->checkMethodAuthorization()); // Test authority-based method $_GET['action'] = 'create'; - SecurityContext::setAuthorities(['USER_READ']); + SecurityContext::setCurrentUser(new TestUser(1, [], ['USER_READ'], true)); $this->assertFalse($service->checkMethodAuthorization()); - SecurityContext::setAuthorities(['USER_CREATE']); + SecurityContext::setCurrentUser(new TestUser(1, [], ['USER_CREATE'], true)); $this->assertTrue($service->checkMethodAuthorization()); } diff --git a/tests/WebFiori/Tests/Http/HttpMessageTest.php b/tests/WebFiori/Tests/Http/HttpMessageTest.php index 1ee91cd..8ab32df 100644 --- a/tests/WebFiori/Tests/Http/HttpMessageTest.php +++ b/tests/WebFiori/Tests/Http/HttpMessageTest.php @@ -181,4 +181,11 @@ public function testMultipleInstances() { $this->assertTrue($message2->hasHeader('Accept')); $this->assertFalse($message2->hasHeader('Content-Type')); } + + public function testGetBody() { + $message = new \WebFiori\Http\Response(); + $message->write('test body'); + $this->assertEquals('test body', $message->getBody()); + } } + diff --git a/tests/WebFiori/Tests/Http/RequestParameterTest.php b/tests/WebFiori/Tests/Http/RequestParameterTest.php index 4822eab..c7a9000 100644 --- a/tests/WebFiori/Tests/Http/RequestParameterTest.php +++ b/tests/WebFiori/Tests/Http/RequestParameterTest.php @@ -673,4 +673,38 @@ public function testRequestMethod00() { $rp->addMethods(['geT', 'PoSt ']); $this->assertEquals(['GET', 'POST'], $rp->getMethods()); } + + public function testReservedParameterName() { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('reserved'); + new RequestParameter('service', 'string'); + } + + public function testToJSONWithMethods() { + $rp = new RequestParameter('user-id', 'integer'); + $rp->addMethod('POST'); + $json = $rp->toJSON(); + $this->assertEquals('body', $json->get('in')); + } + + public function testSetCustomFilter() { + $rp = new RequestParameter('custom', 'string'); + $rp->setCustomFilterFunction(function($val) { + return strtoupper($val); + }); + $this->assertNotNull($rp->getCustomFilterFunction()); + } + + public function testSetDescription() { + $rp = new RequestParameter('test', 'string'); + $rp->setDescription('Test parameter'); + $this->assertEquals('Test parameter', $rp->getDescription()); + } + + public function testIsEmptyStringAllowed() { + $rp = new RequestParameter('test', 'string'); + $rp->setIsEmptyStringAllowed(true); + $this->assertTrue($rp->isEmptyStringAllowed()); + } } + diff --git a/tests/WebFiori/Tests/Http/RequestTest.php b/tests/WebFiori/Tests/Http/RequestTest.php index 91abc0c..c8cd1e0 100644 --- a/tests/WebFiori/Tests/Http/RequestTest.php +++ b/tests/WebFiori/Tests/Http/RequestTest.php @@ -214,4 +214,44 @@ public function testGetCookie00() { $this->request = Request::createFromGlobals(); $this->assertEquals('cool_cookie', $this->request->getCookieValue('cool')); } + + public function testGetPathWithScriptName() { + unset($_SERVER['REQUEST_URI']); + $_SERVER['SCRIPT_NAME'] = '/api/index.php'; + $this->request = Request::createFromGlobals(); + $path = $this->request->getPath(); + $this->assertNotEmpty($path); + } + + public function testGetUri() { + $_SERVER['REQUEST_URI'] = '/api/users'; + $this->request = Request::createFromGlobals(); + $uri = $this->request->getUri(); + $this->assertInstanceOf(\WebFiori\Http\RequestUri::class, $uri); + } + + public function testGetRequestedURIWithPath() { + $_SERVER['REQUEST_URI'] = '/api/test'; + $_SERVER['HTTP_HOST'] = 'example.com'; + $this->request = Request::createFromGlobals(); + $uri = $this->request->getRequestedURI('extra/path'); + $this->assertStringContainsString('extra/path', $uri); + } + + public function testGetPathReturnsSlashWhenNull() { + unset($_SERVER['REQUEST_URI']); + unset($_SERVER['SCRIPT_NAME']); + $this->request = Request::createFromGlobals(); + $path = $this->request->getPath(); + $this->assertEquals('/', $path); + } + + public function testGetRequestedURIWithTrailingSlash() { + $_SERVER['REQUEST_URI'] = '/api/'; + $_SERVER['HTTP_HOST'] = 'example.com'; + $this->request = Request::createFromGlobals(); + $uri = $this->request->getRequestedURI('users'); + $this->assertStringContainsString('users', $uri); + } } + diff --git a/tests/WebFiori/Tests/Http/RequestUriTest.php b/tests/WebFiori/Tests/Http/RequestUriTest.php index 6be29ab..ea34af7 100644 --- a/tests/WebFiori/Tests/Http/RequestUriTest.php +++ b/tests/WebFiori/Tests/Http/RequestUriTest.php @@ -196,4 +196,13 @@ public function testSetUriPossibleVar03() { $this->assertEquals(['hell','is','not','heven'], $uri->getAllowedParameterValues('second-var')); $this->assertEquals([], $uri->getAllowedParameterValues('secohhnd-var')); } + + public function testEqualsWithDifferentMethods() { + $uri1 = new RequestUri('https://example.com/test'); + $uri1->addRequestMethod('GET'); + $uri2 = new RequestUri('https://example.com/test'); + $uri2->addRequestMethod('POST'); + $this->assertFalse($uri1->equals($uri2)); + } } + diff --git a/tests/WebFiori/Tests/Http/ResponseTest.php b/tests/WebFiori/Tests/Http/ResponseTest.php index f2f372e..752e0f2 100644 --- a/tests/WebFiori/Tests/Http/ResponseTest.php +++ b/tests/WebFiori/Tests/Http/ResponseTest.php @@ -230,4 +230,54 @@ public function testClear() { $this->assertEquals('', $response->getBody()); $this->assertEquals([], $response->getHeaders()); } + + public function testAppendBody() { + $response = new Response(); + $response->write('First '); + $response->write('Second'); + $this->assertEquals('First Second', $response->getBody()); + } + + public function testGetHeadersPool() { + $response = new Response(); + $pool = $response->getHeadersPool(); + $this->assertInstanceOf(\WebFiori\Http\HeadersPool::class, $pool); + } + + public function testHasCookie() { + $response = new Response(); + $this->assertFalse($response->hasCookie('test')); + } + + public function testGetCookies() { + $response = new Response(); + $cookies = $response->getCookies(); + $this->assertIsArray($cookies); + } + + public function testSetCode() { + $response = new Response(); + $response->setCode(404); + $this->assertEquals(404, $response->getCode()); + } + + public function testIsSent() { + $response = new Response(); + $this->assertFalse($response->isSent()); + } + + public function testRemoveHeader() { + $response = new Response(); + $response->addHeader('X-Test', 'value'); + $response->removeHeader('X-Test'); + $this->assertFalse($response->hasHeader('X-Test')); + } + + public function testClearHeaders() { + $response = new Response(); + $response->addHeader('X-Test', 'value'); + $response->clearHeaders(); + $this->assertEmpty($response->getHeaders()); + } } + diff --git a/tests/WebFiori/Tests/Http/SchemaTest.php b/tests/WebFiori/Tests/Http/SchemaTest.php new file mode 100644 index 0000000..4ef3994 --- /dev/null +++ b/tests/WebFiori/Tests/Http/SchemaTest.php @@ -0,0 +1,56 @@ +toJSON(); + $this->assertEquals('string', $json->get('type')); + } + + public function testSchemaInteger() { + $schema = new Schema('integer'); + $json = $schema->toJSON(); + $this->assertEquals('integer', $json->get('type')); + } + + public function testFromRequestParameterEmail() { + $param = new RequestParameter('email', ParamType::EMAIL); + $schema = Schema::fromRequestParameter($param); + $json = $schema->toJSON(); + $this->assertEquals('email', $json->get('format')); + } + + public function testFromRequestParameterUrl() { + $param = new RequestParameter('website', ParamType::URL); + $schema = Schema::fromRequestParameter($param); + $json = $schema->toJSON(); + $this->assertEquals('uri', $json->get('format')); + } + + public function testFromRequestParameterWithMinMax() { + $param = new RequestParameter('age', ParamType::INT); + $param->setMinValue(0); + $param->setMaxValue(120); + $schema = Schema::fromRequestParameter($param); + $json = $schema->toJSON(); + $this->assertEquals(0, $json->get('minimum')); + $this->assertEquals(120, $json->get('maximum')); + } + + public function testFromRequestParameterWithLength() { + $param = new RequestParameter('name', ParamType::STRING); + $param->setMinLength(2); + $param->setMaxLength(50); + $schema = Schema::fromRequestParameter($param); + $json = $schema->toJSON(); + $this->assertEquals(2, $json->get('minLength')); + $this->assertEquals(50, $json->get('maxLength')); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/AccessDeniedTestService.php b/tests/WebFiori/Tests/Http/TestServices/AccessDeniedTestService.php new file mode 100644 index 0000000..4447cbf --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AccessDeniedTestService.php @@ -0,0 +1,18 @@ +addRequestMethod('GET'); + } + + public function isAuthorized(): bool { + return false; + } + + public function processRequest() { + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/AnnotatedAuthFailService.php b/tests/WebFiori/Tests/Http/TestServices/AnnotatedAuthFailService.php new file mode 100644 index 0000000..c614b5a --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AnnotatedAuthFailService.php @@ -0,0 +1,25 @@ + 'test']; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/AnnotatedMethodService.php b/tests/WebFiori/Tests/Http/TestServices/AnnotatedMethodService.php new file mode 100644 index 0000000..85665b9 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AnnotatedMethodService.php @@ -0,0 +1,27 @@ + $_GET['id'] ?? 0, 'name' => 'Test Item']; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/AutoDiscoveredService.php b/tests/WebFiori/Tests/Http/TestServices/AutoDiscoveredService.php new file mode 100644 index 0000000..f277f05 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AutoDiscoveredService.php @@ -0,0 +1,18 @@ +sendResponse('Auto-discovered service works'); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ConflictingAnnotationsService.php b/tests/WebFiori/Tests/Http/TestServices/ConflictingAnnotationsService.php new file mode 100644 index 0000000..2bd0d17 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ConflictingAnnotationsService.php @@ -0,0 +1,27 @@ + 'test']; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ExceptionTestService.php b/tests/WebFiori/Tests/Http/TestServices/ExceptionTestService.php index 42ccd87..e6523fb 100644 --- a/tests/WebFiori/Tests/Http/TestServices/ExceptionTestService.php +++ b/tests/WebFiori/Tests/Http/TestServices/ExceptionTestService.php @@ -1,79 +1,43 @@ addRequestMethod('GET'); + $this->addRequestMethod('POST'); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + } - #[GetMapping] #[ResponseBody] - #[RequestParam('id', 'int')] - public function getUser(): array { - $id = $this->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) { + public function getUser() { + $testId = $_GET['test_id'] ?? 0; + if ($testId == 404) { throw new NotFoundException('User not found'); } - - if ($id === 400) { + if ($testId == 400) { throw new BadRequestException('Invalid user ID'); } - - return ['user' => ['id' => $id, 'name' => 'Test User']]; + return ['id' => $testId]; } - #[PostMapping] - #[ResponseBody] - public function createUser(): array { + public function createUser() { throw new UnauthorizedException('Authentication required'); } - #[GetMapping] - #[ResponseBody] - public function getError(): array { + public function getError() { 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/GenericExceptionMethodService.php b/tests/WebFiori/Tests/Http/TestServices/GenericExceptionMethodService.php new file mode 100644 index 0000000..7acc6ba --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/GenericExceptionMethodService.php @@ -0,0 +1,25 @@ +addRequestMethod('GET'); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + throw new HttpException('Not found', 404); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/JsonBodyService.php b/tests/WebFiori/Tests/Http/TestServices/JsonBodyService.php new file mode 100644 index 0000000..280df77 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/JsonBodyService.php @@ -0,0 +1,22 @@ +addRequestMethod('POST'); + $this->addParameter(new RequestParameter('data', 'string')); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $inputs = $this->getInputs(); + $this->sendResponse('JSON received', 200, 'success', $inputs); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/MethodAuthFailService.php b/tests/WebFiori/Tests/Http/TestServices/MethodAuthFailService.php new file mode 100644 index 0000000..f859750 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/MethodAuthFailService.php @@ -0,0 +1,25 @@ + 'test']; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/PatchTestService.php b/tests/WebFiori/Tests/Http/TestServices/PatchTestService.php new file mode 100644 index 0000000..cd0e46e --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/PatchTestService.php @@ -0,0 +1,22 @@ +addRequestMethod('PATCH'); + $this->addParameter(new RequestParameter('field', 'string')); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $inputs = $this->getInputs(); + $this->sendResponse('PATCH received', 200, 'success', $inputs); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/PutTestService.php b/tests/WebFiori/Tests/Http/TestServices/PutTestService.php new file mode 100644 index 0000000..16e8f49 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/PutTestService.php @@ -0,0 +1,23 @@ +addRequestMethod('PUT'); + $this->addParameter(new RequestParameter('name', 'string')); + $this->addParameter(new RequestParameter('value', 'integer')); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $inputs = $this->getInputs(); + $this->sendResponse('PUT received', 200, 'success', $inputs); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ResponseBodyService.php b/tests/WebFiori/Tests/Http/TestServices/ResponseBodyService.php new file mode 100644 index 0000000..5738917 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ResponseBodyService.php @@ -0,0 +1,32 @@ + 123, 'name' => 'New Resource']; + } + + #[GetMapping] + #[ResponseBody(contentType: 'text/plain')] + public function getPlainText() { + return 'Plain text response'; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/SecureService.php b/tests/WebFiori/Tests/Http/TestServices/SecureService.php index 5e98b18..d0ec2d0 100644 --- a/tests/WebFiori/Tests/Http/TestServices/SecureService.php +++ b/tests/WebFiori/Tests/Http/TestServices/SecureService.php @@ -39,7 +39,7 @@ public function createUser() { } public function isAuthorized(): bool { - return true; // Default fallback + return SecurityContext::isAuthenticated(); } public function processRequest() { diff --git a/tests/WebFiori/Tests/Http/TestServices/SimpleExceptionService.php b/tests/WebFiori/Tests/Http/TestServices/SimpleExceptionService.php new file mode 100644 index 0000000..6393665 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/SimpleExceptionService.php @@ -0,0 +1,19 @@ +addRequestMethod('GET'); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + throw new \Exception('Test exception'); + } +} diff --git a/tests/WebFiori/Tests/Http/TestUser.php b/tests/WebFiori/Tests/Http/TestUser.php index 51190e4..9d13330 100644 --- a/tests/WebFiori/Tests/Http/TestUser.php +++ b/tests/WebFiori/Tests/Http/TestUser.php @@ -1,19 +1,21 @@ id = $id; + $this->name = $name; $this->roles = $roles; $this->authorities = $authorities; $this->active = $active; @@ -23,6 +25,10 @@ public function getId(): int|string { return $this->id; } + public function getName(): string { + return $this->name; + } + public function getRoles(): array { return $this->roles; } diff --git a/tests/WebFiori/Tests/Http/UriTest.php b/tests/WebFiori/Tests/Http/UriTest.php index 2a2ba9b..f3d800c 100644 --- a/tests/WebFiori/Tests/Http/UriTest.php +++ b/tests/WebFiori/Tests/Http/UriTest.php @@ -370,4 +370,11 @@ public function testSplitURI_16() { $uri = 'https://programmingacademia.com/{?}/{another}/not/super'; $uriObj = new RequestUri($uri); } + + public function testInvalidUri() { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid URI'); + new Uri('not a valid uri!!!'); + } } + diff --git a/tests/WebFiori/Tests/Http/WebServiceTest.php b/tests/WebFiori/Tests/Http/WebServiceTest.php index 535f905..2d0b261 100644 --- a/tests/WebFiori/Tests/Http/WebServiceTest.php +++ b/tests/WebFiori/Tests/Http/WebServiceTest.php @@ -413,4 +413,18 @@ public function processRequest() {} $this->assertContains('DELETE', $methods); $this->assertCount(4, $methods); } + + public function testConflictingAnnotations() { + $service = new \WebFiori\Tests\Http\TestServices\ConflictingAnnotationsService(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('conflicting annotations'); + + $service->checkMethodAuthorization(); + } } + + + + + diff --git a/tests/WebFiori/Tests/Http/WebServicesManagerTest.php b/tests/WebFiori/Tests/Http/WebServicesManagerTest.php index 852fccb..8e747e5 100644 --- a/tests/WebFiori/Tests/Http/WebServicesManagerTest.php +++ b/tests/WebFiori/Tests/Http/WebServicesManagerTest.php @@ -674,6 +674,192 @@ private function clrearVars() { unset($_SERVER['CONTENT_TYPE']); putenv('REQUEST_METHOD'); } + + public function testAutoDiscoverServices() { + $manager = new WebServicesManager(); + $servicesPath = __DIR__ . '/TestServices'; + $manager->autoDiscoverServices($servicesPath); + + $services = $manager->getServices(); + $this->assertGreaterThan(0, count($services)); + + $found = false; + foreach ($services as $service) { + if ($service->getName() === 'auto-discovered') { + $found = true; + break; + } + } + $this->assertTrue($found, 'AutoDiscoveredService should be found'); + } + + public function testGetResponse() { + $manager = new WebServicesManager(); + $response = $manager->getResponse(); + $this->assertNotNull($response); + } + + public function testSetAndGetBasePath() { + $manager = new WebServicesManager(); + $manager->setBasePath('/api/v1/'); + $this->assertEquals('/api/v1', $manager->getBasePath()); + + $manager->setBasePath('/api/v2'); + $this->assertEquals('/api/v2', $manager->getBasePath()); + } + + public function testRemoveServices() { + $manager = new WebServicesManager(); + $manager->addService(new NoAuthService()); + $this->assertGreaterThan(0, count($manager->getServices())); + + $manager->removeServices(); + $this->assertEquals(0, count($manager->getServices())); + } + + public function testSendWithOutputStream() { + $manager = new WebServicesManager(); + $outputFile = __DIR__ . '/test-output.txt'; + $manager->setOutputStream(fopen($outputFile, 'w')); + + $manager->send('text/plain', 'Test content', 200); + + $this->assertFileExists($outputFile); + $content = file_get_contents($outputFile); + $this->assertEquals('Test content', $content); + + unlink($outputFile); + } + + public function testAutoDiscoverServicesWithNullPath() { + $manager = new WebServicesManager(); + $result = $manager->autoDiscoverServices(); + $this->assertInstanceOf(WebServicesManager::class, $result); + } + + public function testToJSON() { + $manager = new WebServicesManager(); + $manager->setVersion('1.0.0'); + $manager->setDescription('Test API'); + $manager->addService(new NoAuthService()); + + $json = $manager->toJSON(); + $this->assertNotNull($json); + $this->assertEquals('1.0.0', $json->get('api-version')); + $this->assertEquals('Test API', $json->get('description')); + } + + public function testToJSONWithVersion() { + $manager = new WebServicesManager(); + $manager->setVersion('2.0.0'); + $service = new NoAuthService(); + $service->setSince('2.0.0'); + $manager->addService($service); + + $_GET['version'] = '2.0.0'; + $json = $manager->toJSON(); + $services = $json->get('services'); + $this->assertNotEmpty($services); + unset($_GET['version']); + } + + public function testToOpenAPI() { + $manager = new WebServicesManager(); + $manager->setVersion('1.0.0'); + $manager->setDescription('Test API'); + $manager->addService(new NoAuthService()); + + $openapi = $manager->toOpenAPI(); + $this->assertInstanceOf(\WebFiori\Http\OpenAPI\OpenAPIObj::class, $openapi); + } + + public function testPutRequest() { + $manager = new WebServicesManager(); + $manager->addService(new \WebFiori\Tests\Http\TestServices\PutTestService()); + + $response = $this->putRequest($manager, 'put-test', [ + 'name' => 'test', + 'value' => 123 + ]); + + $this->assertStringContainsString('PUT received', $response); + } + + public function testPatchRequest() { + $manager = new WebServicesManager(); + $manager->addService(new \WebFiori\Tests\Http\TestServices\PatchTestService()); + + $response = $this->patchRequest($manager, 'patch-test', [ + 'field' => 'updated' + ]); + + $this->assertStringContainsString('PATCH received', $response); + } + + public function testToJSONWithVersionFilter() { + $manager = new WebServicesManager(); + $manager->setVersion('2.0.0'); + + $service1 = new NoAuthService(); + $service1->setSince('1.0.0'); + $manager->addService($service1); + + $service2 = new \WebFiori\Tests\Http\TestServices\PutTestService(); + $service2->setSince('2.0.0'); + $manager->addService($service2); + + $_POST['version'] = '2.0.0'; + $json = $manager->toJSON(); + $services = $json->get('services'); + $this->assertGreaterThan(0, count($services)); + unset($_POST['version']); + } + + public function testAnnotatedMethodService() { + $manager = new WebServicesManager(); + $manager->addService(new \WebFiori\Tests\Http\TestServices\AnnotatedMethodService()); + + $response = $this->getRequest($manager, 'annotated-method', ['id' => 123]); + + $this->assertStringContainsString('Test Item', $response); + } + + public function testJsonBodyRequest() { + $manager = new WebServicesManager(); + $manager->addService(new \WebFiori\Tests\Http\TestServices\JsonBodyService()); + + $response = $this->postRequest($manager, 'json-body', ['data' => 'test']); + + $this->assertStringContainsString('JSON received', $response); + } + + public function testMethodAuthorizationFailure() { + $manager = new WebServicesManager(); + $manager->addService(new \WebFiori\Tests\Http\TestServices\MethodAuthFailService()); + + $response = $this->getRequest($manager, 'method-auth-fail'); + + $this->assertStringContainsString('http-code', $response); + } + + public function testHttpExceptionInMethod() { + $manager = new WebServicesManager(); + $manager->addService(new \WebFiori\Tests\Http\TestServices\HttpExceptionMethodService()); + + $response = $this->getRequest($manager, 'http-exception-method'); + + $this->assertStringContainsString('404', $response); + } + + public function testGenericExceptionInMethod() { + $manager = new WebServicesManager(); + $manager->addService(new \WebFiori\Tests\Http\TestServices\GenericExceptionMethodService()); + + $response = $this->getRequest($manager, 'generic-exception-method'); + + $this->assertStringContainsString('500', $response); + } + public static function setTestJson($fName, $jsonData) { $stream = fopen($fName, 'w+'); fwrite($stream, $jsonData);