diff --git a/.gitignore b/.gitignore index 7742743..2a0b5bc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ php-cs-fixer.phar *.Identifier /tests/.phpunit.cache /.vscode +tests/WebFiori/Tests/Http/output-stream.txt +/OpenAPI_files diff --git a/WebFiori/Http/APIFilter.php b/WebFiori/Http/APIFilter.php index ea9551a..19fddfa 100644 --- a/WebFiori/Http/APIFilter.php +++ b/WebFiori/Http/APIFilter.php @@ -21,6 +21,7 @@ * */ class APIFilter { + private $requestParameters = []; /** * A constant that indicates a given value is invalid. * @@ -103,6 +104,10 @@ public function addRequestParameter(RequestParameter $reqParam) { $attribute[$filterIdx][] = FILTER_DEFAULT; } $this->paramDefs[] = $attribute; + $this->requestParameters[] = $reqParam; + } + public function getParameters() : array { + return $this->requestParameters; } /** * Clears the arrays that are used to store filtered and not-filtered variables. @@ -118,6 +123,7 @@ public function clearInputs() { */ public function clearParametersDef() { $this->paramDefs = []; + $this->requestParameters = []; } /** * Filter the values of an associative array. diff --git a/WebFiori/Http/APITestCase.php b/WebFiori/Http/APITestCase.php index 30c9208..e64ffc6 100644 --- a/WebFiori/Http/APITestCase.php +++ b/WebFiori/Http/APITestCase.php @@ -23,13 +23,38 @@ */ class APITestCase extends TestCase { const NL = "\r\n"; - const DEFAULT_OUTPUT_STREAM = __DIR__.DIRECTORY_SEPARATOR.'outputStream.txt'; + const DEFAULT_OUTPUT_STREAM = __DIR__.DIRECTORY_SEPARATOR.'output-stream.txt'; /** * The path to the output stream file. * * @var string */ private $outputStreamPath; + /** + * Backup of global variables. + * + * @var array + */ + private $globalsBackup; + + protected function setUp(): void { + parent::setUp(); + $this->globalsBackup = [ + 'GET' => $_GET, + 'POST' => $_POST, + 'FILES' => $_FILES, + 'SERVER' => $_SERVER + ]; + } + + protected function tearDown(): void { + $_GET = $this->globalsBackup['GET']; + $_POST = $this->globalsBackup['POST']; + $_FILES = $this->globalsBackup['FILES']; + $_SERVER = $this->globalsBackup['SERVER']; + SecurityContext::clear(); + parent::tearDown(); + } /** * Sets the path to the file which is used to store API output temporarily. * @@ -74,7 +99,7 @@ public function addFile(string $fileIdx, string $filePath, bool $reset = false) $_FILES[$fileIdx]['error'] = []; } $info = $this->extractPathAndName($filePath); - $path = $info['path'].DS.$info['name']; + $path = $info['path'].DIRECTORY_SEPARATOR.$info['name']; $_FILES[$fileIdx]['name'][] = $info['name']; $_FILES[$fileIdx]['type'][] = mime_content_type($path); @@ -102,47 +127,109 @@ public function addFile(string $fileIdx, string $filePath, bool $reset = false) * @param array $httpHeaders An optional associative array that can be used * to mimic HTTP request headers. The keys of the array are names of headers * and the value of each key represents the value of the header. + * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. * * @return string The method will return the output of the endpoint. */ - public function callEndpoint(WebServicesManager $manager, string $requestMethod, string $apiEndpointName, array $parameters = [], array $httpHeaders = []) : string { - $manager->setOutputStream(fopen($this->getOutputFile(),'w')); + public function callEndpoint(WebServicesManager $manager, string $requestMethod, string $apiEndpointName, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { $method = strtoupper($requestMethod); - putenv('REQUEST_METHOD='.$method); + $serviceName = $this->resolveServiceName($apiEndpointName); + + $this->setupRequest($method, $serviceName, $parameters, $httpHeaders); + + $manager->setOutputStream(fopen($this->getOutputFile(), 'w')); + $manager->setRequest(Request::createFromGlobals()); + SecurityContext::setCurrentUser($user); + $manager->process(); + + $result = $manager->readOutputStream(); - if (class_exists($apiEndpointName)) { - $service = new $apiEndpointName(); + if (file_exists($this->getOutputFile())) { + unlink($this->getOutputFile()); + } + + return $this->formatOutput($result); + } + + /** + * Resolves service name from class name or returns the name as-is. + * + * @param string $nameOrClass Service name or class name + * + * @return string The resolved service name + */ + private function resolveServiceName(string $nameOrClass): string { + if (class_exists($nameOrClass)) { + $reflection = new \ReflectionClass($nameOrClass); - if ($service instanceof AbstractWebService) { - $apiEndpointName = $service->getName(); + if ($reflection->isSubclassOf(WebService::class)) { + $constructor = $reflection->getConstructor(); + + if ($constructor && $constructor->getNumberOfRequiredParameters() === 0) { + $service = $reflection->newInstance(); + return $service->getName(); + } } } - if ($method == RequestMethod::POST || $method == RequestMethod::PUT || $method == RequestMethod::PATCH) { - foreach ($parameters as $key => $val) { - $_POST[$key] = $this->parseVal($val); - } - $_POST['service'] = $apiEndpointName; - $_SERVER['CONTENT_TYPE'] = 'multipart/form-data'; - $this->unset($_POST, $parameters, $manager, $httpHeaders); + + return $nameOrClass; + } + + /** + * Sets up the request environment. + * + * @param string $method HTTP method + * @param string $serviceName Service name + * @param array $parameters Request parameters + * @param array $httpHeaders An optional associative array that can be used + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * + * @param UserInterface|null $user Optional user to authenticate the request with. + */ + private function setupRequest(string $method, string $serviceName, array $parameters, array $httpHeaders) { + putenv('REQUEST_METHOD=' . $method); + + // Normalize header names to lowercase for case-insensitive comparison + $normalizedHeaders = []; + foreach ($httpHeaders as $name => $value) { + $normalizedHeaders[strtolower($name)] = $value; + } + + if (in_array($method, [RequestMethod::POST, RequestMethod::PUT, RequestMethod::PATCH])) { + $_POST = $parameters; + $_POST['service'] = $serviceName; + $_SERVER['CONTENT_TYPE'] = $normalizedHeaders['content-type'] ?? 'application/x-www-form-urlencoded'; } else { - foreach ($parameters as $key => $val) { - $_GET[$key] = $this->parseVal($val); - } - $_GET['service'] = $apiEndpointName; - $this->unset($_GET, $parameters, $manager, $httpHeaders); + $_GET = $parameters; + $_GET['service'] = $serviceName; } - - $retVal = $manager->readOutputStream(); - unlink($this->getOutputFile()); + foreach ($normalizedHeaders as $name => $value) { + if ($name !== 'content-type') { + $_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; + } + } + } + + /** + * Formats the output, attempting to pretty-print JSON if possible. + * + * @param string $output Raw output + * + * @return string Formatted output + */ + private function formatOutput(string $output): string { try { - $json = Json::decode($retVal); + $json = Json::decode($output); $json->setIsFormatted(true); - return $json.''; + return $json . ''; } catch (JsonException $ex) { - return $retVal; + return $output; } - } /** * Creates a formatted string from calling an API. @@ -166,26 +253,6 @@ public function format(string $output) { echo ". '$expl[$x]]'".$nl; } } - private function parseVal($val) { - $type = gettype($val); - - if ($type == 'array') { - $array = []; - - foreach ($val as $arrVal) { - if (gettype($val) == 'string') { - $array[] = "'".$arrVal."'"; - } else { - $array[] = $arrVal; - } - } - - return implode(',', $array); - } else if ($type == 'boolean') { - return $type === true ? 'y' : 'n'; - } - return $val; - } /** * Sends a DELETE request to specific endpoint. * @@ -200,11 +267,15 @@ private function parseVal($val) { * to mimic HTTP request headers. The keys of the array are names of headers * and the value of each key represents the value of the header. * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * * @return string The method will return the output that was produced by * the endpoint as string. */ - public function deletRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = []) : string { - return $this->callEndpoint($manager, RequestMethod::DELETE, $endpoint, $parameters, $httpHeaders); + public function deleteRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::DELETE, $endpoint, $parameters, $httpHeaders, $user); } /** * Sends a GET request to specific endpoint. @@ -219,8 +290,8 @@ public function deletRequest(WebServicesManager $manager, string $endpoint, arra * @return string The method will return the output that was produced by * the endpoint as string. */ - public function getRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = []) : string { - return $this->callEndpoint($manager, RequestMethod::GET, $endpoint, $parameters, $httpHeaders); + public function getRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::GET, $endpoint, $parameters, $httpHeaders, $user); } /** * Sends a POST request to specific endpoint. @@ -236,11 +307,15 @@ public function getRequest(WebServicesManager $manager, string $endpoint, array * to mimic HTTP request headers. The keys of the array are names of headers * and the value of each key represents the value of the header. * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * * @return string The method will return the output that was produced by * the endpoint as string. */ - public function postRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = []) : string { - return $this->callEndpoint($manager, RequestMethod::POST, $endpoint, $parameters, $httpHeaders); + public function postRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::POST, $endpoint, $parameters, $httpHeaders, $user); } /** * Sends a PUT request to specific endpoint. @@ -256,11 +331,87 @@ public function postRequest(WebServicesManager $manager, string $endpoint, array * to mimic HTTP request headers. The keys of the array are names of headers * and the value of each key represents the value of the header. * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * + * @return string The method will return the output that was produced by + * the endpoint as string. + */ + public function putRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::PUT, $endpoint, $parameters, $httpHeaders, $user); + } + /** + * Sends a PATCH request to specific endpoint. + * + * @param WebServicesManager $manager The manager which is used to manage the endpoint. + * + * @param string $endpoint The name of the endpoint. + * + * @param array $parameters An optional array of request parameters that can be + * passed to the endpoint. + * + * @param array $httpHeaders An optional associative array that can be used + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * * @return string The method will return the output that was produced by * the endpoint as string. */ - public function putRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = []) : string { - return $this->callEndpoint($manager, RequestMethod::PUT, $endpoint, $parameters, $httpHeaders); + public function patchRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::PATCH, $endpoint, $parameters, $httpHeaders, $user); + } + /** + * Sends an OPTIONS request to specific endpoint. + * + * @param WebServicesManager $manager The manager which is used to manage the endpoint. + * + * @param string $endpoint The name of the endpoint. + * + * @param array $parameters An optional array of request parameters that can be + * passed to the endpoint. + * + * @param array $httpHeaders An optional associative array that can be used + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * + * @return string The method will return the output that was produced by + * the endpoint as string. + */ + public function optionsRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::OPTIONS, $endpoint, $parameters, $httpHeaders, $user); + } + /** + * Sends a HEAD request to specific endpoint. + * + * @param WebServicesManager $manager The manager which is used to manage the endpoint. + * + * @param string $endpoint The name of the endpoint. + * + * @param array $parameters An optional array of request parameters that can be + * passed to the endpoint. + * + * @param array $httpHeaders An optional associative array that can be used + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * + * @param UserInterface|null $user Optional user to authenticate the request with. + * to mimic HTTP request headers. The keys of the array are names of headers + * and the value of each key represents the value of the header. + * + * @return string The method will return the output that was produced by + * the endpoint as string. + */ + public function headRequest(WebServicesManager $manager, string $endpoint, array $parameters = [], array $httpHeaders = [], ?UserInterface $user = null) : string { + return $this->callEndpoint($manager, RequestMethod::HEAD, $endpoint, $parameters, $httpHeaders, $user); } private function extractPathAndName($absPath): array { $DS = DIRECTORY_SEPARATOR; @@ -286,24 +437,4 @@ private function extractPathAndName($absPath): array { 'path' => '' ]; } - private function unset(array &$arr, array $params, WebServicesManager $m, array $httpHeaders) { - foreach ($httpHeaders as $header => $value) { - $trHeader = trim($header.''); - $trVal = trim($value.''); - if (strlen($trHeader) != 0) { - $_SERVER['HTTP_'.strtoupper($trHeader)] = $trVal; - } - } - $m->setRequest(Request::createFromGlobals()); - $m->process(); - - foreach ($params as $key => $val) { - unset($arr[$key]); - } - - foreach ($httpHeaders as $header => $value) { - $trHeader = trim($header.''); - unset($_SERVER['HTTP_'.strtoupper($trHeader)]); - } - } } diff --git a/WebFiori/Http/AbstractWebService.php b/WebFiori/Http/AbstractWebService.php index f6b97bb..516be9a 100644 --- a/WebFiori/Http/AbstractWebService.php +++ b/WebFiori/Http/AbstractWebService.php @@ -1,844 +1,8 @@ - *
  • It can contain the letters [A-Z] and [a-z].
  • - *
  • It can contain the numbers [0-9].
  • - *
  • It can have the character '-' and the character '_'.
  • - * - * If The given name is invalid, the name of the service will be set to 'new-service'. - * - * @param string $name The name of the web service. - * - * @param WebServicesManager|null $owner The manager which is used to - * manage the web service. - */ - public function __construct(string $name) { - if (!$this->setName($name)) { - $this->setName('new-service'); - } - $this->reqMethods = []; - $this->parameters = []; - $this->responses = []; - $this->requireAuth = true; - $this->sinceVersion = '1.0.0'; - $this->serviceDesc = ''; - $this->request = Request::createFromGlobals(); - } - /** - * Returns an array that contains all possible requests methods at which the - * service can be called with. - * - * The array will contain strings like 'GET' or 'POST'. If no request methods - * where added, the array will be empty. - * - * @return array An array that contains all possible requests methods at which the - * service can be called using. - * - */ - public function &getRequestMethods() : array { - return $this->reqMethods; - } - /** - * Returns an array that contains an objects of type RequestParameter. - * - * @return array an array that contains an objects of type RequestParameter. - * - */ - public final function &getParameters() : array { - return $this->parameters; - } - /** - * - * @return string - * - */ - public function __toString() { - $retVal = "APIAction[\n"; - $retVal .= " Name => '".$this->getName()."',\n"; - $retVal .= " Description => '".$this->getDescription()."',\n"; - $since = $this->getSince() === null ? 'null' : $this->getSince(); - $retVal .= " Since => '$since',\n"; - $reqMethodsStr = "[\n"; - $comma = ','; - - for ($x = 0, $count = count($this->getRequestMethods()) ; $x < $count ; $x++) { - $meth = $this->getRequestMethods()[$x]; - - if ($x + 1 == $count) { - $comma = ''; - } - $reqMethodsStr .= " $meth$comma\n"; - } - $reqMethodsStr .= " ],\n"; - $retVal .= " Request Methods => $reqMethodsStr"; - $paramsStr = "[\n"; - - $comma = ','; - - for ($x = 0 , $count = count($this->getParameters()); $x < $count ; $x++) { - $param = $this->getParameters()[$x]; - $paramsStr .= " ".$param->getName()." => [\n"; - $paramsStr .= " Type => '".$param->getType()."',\n"; - $descStr = $param->getDescription() === null ? 'null' : $param->getDescription(); - $paramsStr .= " Description => '$descStr',\n"; - $isOptional = $param->isOptional() ? 'true' : 'false'; - $paramsStr .= " Is Optional => '$isOptional',\n"; - $defaultStr = $param->getDefault() === null ? 'null' : $param->getDefault(); - $paramsStr .= " Default => '$defaultStr',\n"; - $min = $param->getMinValue() === null ? 'null' : $param->getMinValue(); - $paramsStr .= " Minimum Value => '$min',\n"; - $max = $param->getMaxValue() === null ? 'null' : $param->getMaxValue(); - - if ($x + 1 == $count) { - $comma = ''; - } - $paramsStr .= " Maximum Value => '$max'\n ]$comma\n"; - } - $paramsStr .= " ],\n"; - $retVal .= " Parameters => $paramsStr"; - $responsesStr = "[\n"; - $count = count($this->getResponsesDescriptions()); - $comma = ','; - - for ($x = 0 ; $x < $count ; $x++) { - if ($x + 1 == $count) { - $comma = ''; - } - $responsesStr .= " Response #$x => '".$this->getResponsesDescriptions()[$x]."'".$comma."\n"; - } - $responsesStr .= " ]\n"; - - return $retVal." Responses Descriptions => $responsesStr]\n"; - } - /** - * Adds new request parameter to the service. - * - * The parameter will only be added if no parameter which has the same - * name as the given one is added before. - * - * @param RequestParameter|array $param The parameter that will be added. It - * can be an object of type 'RequestParameter' or an associative array of - * options. The array can have the following indices: - * - * - * @return bool If the given request parameter is added, the method will - * return true. If it was not added for any reason, the method will return - * false. - * - */ - public function addParameter($param) : bool { - if (gettype($param) == 'array') { - $param = RequestParameter::create($param); - } - - if ($param instanceof RequestParameter && !$this->hasParameter($param->getName())) { - $this->parameters[] = $param; - - return true; - } - - return false; - } - /** - * Adds multiple parameters to the web service in one batch. - * - * @param array $params An associative or indexed array. If the array is indexed, - * each index should hold an object of type 'RequestParameter'. If it is associative, - * then the key will represent the name of the web service and the value of the - * key should be a sub-associative array that holds parameter options. - * - */ - public function addParameters(array $params) { - foreach ($params as $paramIndex => $param) { - if ($param instanceof RequestParameter) { - $this->addParameter($param); - } else if (gettype($param) == 'array') { - $param['name'] = $paramIndex; - $this->addParameter(RequestParameter::create($param)); - } - } - } - /** - * Adds new request method. - * - * The value that will be passed to this method can be any string - * that represents HTTP request method (e.g. 'get', 'post', 'options' ...). It - * can be in upper case or lower case. - * - * @param string $method The request method. - * - * @return bool true in case the request method is added. If the given - * request method is already added or the method is unknown, the method - * will return false. - * - */ - public final function addRequestMethod(string $method) : bool { - $uMethod = strtoupper(trim($method)); - - if (in_array($uMethod, RequestMethod::getAll()) && !in_array($uMethod, $this->reqMethods)) { - $this->reqMethods[] = $uMethod; - - return true; - } - - return false; - } - /** - * Adds response description. - * - * It is used to describe the API for front-end developers and help them - * identify possible responses if they call the API using the specified service. - * - * @param string $description A paragraph that describes one of - * the possible responses due to calling the service. - * - */ - public final function addResponseDescription(string $description) { - $trimmed = trim($description); - - if (strlen($trimmed) != 0) { - $this->responses[] = $trimmed; - } - } - /** - * Returns an object that contains the value of the header 'authorization'. - * - * @return AuthHeader|null The object will have two primary attributes, the first is - * the 'scheme' and the second one is 'credentials'. The 'scheme' - * will contain the name of the scheme which is used to authenticate - * ('basic', 'bearer', 'digest', etc...). The 'credentials' will contain - * the credentials which can be used to authenticate the client. - * - */ - public function getAuthHeader() { - if ($this->request !== null) { - return $this->request->getAuthHeader(); - } - return null; - } - - /** - * Sets the request instance for the service. - * - * @param mixed $request The request instance (Request, etc.) - */ - public function setRequest($request) { - $this->request = $request; - } - /** - * Returns the description of the service. - * - * @return string The description of the service. Default is empty string. - * - */ - public final function getDescription() : string { - return $this->serviceDesc; - } - /** - * Returns an associative array or an object of type Json of filtered request inputs. - * - * The indices of the array will represent request parameters and the - * values of each index will represent the value which was set in - * request body. The values will be filtered and might not be exactly the same as - * the values passed in request body. Note that if a parameter is optional and not - * provided in request body, its value will be set to 'null'. Note that - * if request content type is 'application/json', only basic filtering will - * be applied. Also, parameters in this case don't apply. - * - * @return array|Json|null An array of filtered request inputs. This also can - * be an object of type 'Json' if request content type was 'application/json'. - * If no manager was associated with the service, the method will return null. - * - */ - public function getInputs() { - $manager = $this->getManager(); - - if ($manager !== null) { - return $manager->getInputs(); - } - - return null; - } - /** - * Returns the manager which is used to manage the web service. - * - * @return WebServicesManager|null If set, it is returned as an object. - * Other than that, null is returned. - */ - public function getManager() { - return $this->owner; - } - /** - * Returns the name of the service. - * - * @return string The name of the service. - * - */ - public final function getName() : string { - return $this->name; - } - /** - * Map service parameter to specific instance of a class. - * - * This method assumes that every parameter in the request has a method - * that can be called to set attribute value. For example, if a parameter - * has the name 'user-last-name', the mapping method should have the name - * 'setUserLastName' for mapping to work correctly. - * - * @param string $clazz The class that service parameters will be mapped - * to. - * - * @param array $settersMap An optional array that can have custom - * setters map. The indices of the array should be parameters names - * and the values are the names of setter methods in the class. - * - * @return object The Method will return an instance of the class with - * all its attributes set to request parameter's values. - */ - public function getObject(string $clazz, array $settersMap = []) { - $mapper = new ObjectMapper($clazz, $this); - - foreach ($settersMap as $param => $method) { - $mapper->addSetterMap($param, $method); - } - - return $mapper->map($this->getInputs()); - } - /** - * Returns one of the parameters of the service given its name. - * - * @param string $paramName The name of the parameter. - * - * @return RequestParameter|null Returns an objects of type RequestParameter if - * a parameter with the given name was found. null if nothing is found. - * - */ - public final function getParameterByName(string $paramName) { - $trimmed = trim($paramName); - - if (strlen($trimmed) != 0) { - foreach ($this->parameters as $param) { - if ($param->getName() == $trimmed) { - return $param; - } - } - } - - return null; - } - /** - * Returns the value of request parameter given its name. - * - * @param string $paramName The name of request parameter as specified when - * it was added to the service. - * - * @return mixed|null If the parameter is found and its value is set, the - * method will return its value. Other than that, the method will return null. - * For optional parameters, if a default value is set for it, the method will - * return that value. - * - */ - public function getParamVal(string $paramName) { - $inputs = $this->getInputs(); - $trimmed = trim($paramName); - - if ($inputs !== null) { - if ($inputs instanceof Json) { - return $inputs->get($trimmed); - } else { - return $inputs[$trimmed] ?? null; - } - } - - return null; - } - /** - * Returns an indexed array that contains information about possible responses. - * - * It is used to describe the API for front-end developers and help them - * identify possible responses if they call the API using the specified service. - * - * @return array An array that contains information about possible responses. - * - */ - public final function getResponsesDescriptions() : array { - return $this->responses; - } - /** - * Returns version number or name at which the service was added to the API. - * - * Version number is set based on the version number which was set in the - * class WebAPI. - * - * @return string The version number at which the service was added to the API. - * Default is '1.0.0'. - * - */ - public final function getSince() : string { - return $this->sinceVersion; - } - /** - * Checks if the service has a specific request parameter given its name. - * - * Note that the name of the parameter is case-sensitive. This means that - * 'get-profile' is not the same as 'Get-Profile'. - * - * @param string $name The name of the parameter. - * - * @return bool If a request parameter which has the given name is added - * to the service, the method will return true. Otherwise, the method will return - * false. - * - */ - public function hasParameter(string $name) : bool { - $trimmed = trim($name); - - if (strlen($name) != 0) { - foreach ($this->getParameters() as $param) { - if ($param->getName() == $trimmed) { - return true; - } - } - } - - return false; - } - /** - * Checks if the client is authorized to use the service or not. - * - * The developer should implement this method in a way it returns a boolean. - * If the method returns true, it means the client is allowed to use the service. - * If the method returns false, then he is not authorized and a 401 error - * code will be sent back. If the method returned nothing, then it means the - * user is authorized to call the API. If WebFiori framework is used, it is - * possible to perform the functionality of this method using middleware. - * - */ - public function isAuthorized() { - } - /** - * Returns the value of the property 'requireAuth'. - * - * The property is used to tell if the authorization step will be skipped - * or not when the service is called. - * - * @return bool The method will return true if authorization step required. - * False if the authorization step will be skipped. Default return value is true. - * - */ - public function isAuthRequired() : bool { - return $this->requireAuth; - } - - /** - * Validates the name of a web service or request parameter. - * - * @param string $name The name of the service or parameter. - * - * @return bool If valid, true is returned. Other than that, false is returned. - */ - public static function isValidName(string $name): bool { - $trimmedName = trim($name); - $len = strlen($trimmedName); - - if ($len != 0) { - for ($x = 0 ; $x < $len ; $x++) { - $ch = $trimmedName[$x]; - - if (!($ch == '_' || $ch == '-' || ($ch >= 'a' && $ch <= 'z') || ($ch >= 'A' && $ch <= 'Z') || ($ch >= '0' && $ch <= '9'))) { - return false; - } - } - - return true; - } - - return false; - } - /** - * Process client's request. - * - * This method must be implemented in a way it sends back a response after - * processing the request. - * - */ - abstract function processRequest(); - /** - * Removes a request parameter from the service given its name. - * - * @param string $paramName The name of the parameter (case-sensitive). - * - * @return null|RequestParameter If a parameter which has the given name - * was removed, the method will return an object of type 'RequestParameter' - * that represents the removed parameter. If nothing is removed, the - * method will return null. - * - */ - public function removeParameter(string $paramName) { - $trimmed = trim($paramName); - $params = &$this->getParameters(); - $index = -1; - $count = count($params); - - for ($x = 0 ; $x < $count ; $x++) { - if ($params[$x]->getName() == $trimmed) { - $index = $x; - break; - } - } - $retVal = null; - - if ($index != -1) { - if ($count == 1) { - $retVal = $params[0]; - unset($params[0]); - } else { - $retVal = $params[$index]; - $params[$index] = $params[$count - 1]; - unset($params[$count - 1]); - } - } - - return $retVal; - } - /** - * Removes a request method from the previously added ones. - * - * @param string $method The request method (e.g. 'get', 'post', 'options' ...). It - * can be in upper case or lower case. - * - * @return bool If the given request method is remove, the method will - * return true. Other than that, the method will return true. - * - */ - public function removeRequestMethod(string $method): bool { - $uMethod = strtoupper(trim($method)); - $allowedMethods = &$this->getRequestMethods(); - - if (in_array($uMethod, $allowedMethods)) { - $count = count($allowedMethods); - $methodIndex = -1; - - for ($x = 0 ; $x < $count ; $x++) { - if ($this->getRequestMethods()[$x] == $uMethod) { - $methodIndex = $x; - break; - } - } - - if ($count == 1) { - unset($allowedMethods[0]); - } else { - $allowedMethods[$methodIndex] = $allowedMethods[$count - 1]; - unset($allowedMethods[$count - 1]); - } - - return true; - } - - return false; - } - /** - * Sends Back a data using specific content type and specific response code. - * - * @param string $contentType Response content type (such as 'application/json') - * - * @param mixed $data Any data to send back. Mostly, it will be a string. - * - * @param int $code HTTP response code that will be used to send the data. - * Default is HTTP code 200 - Ok. - * - */ - public function send(string $contentType, $data, int $code = 200) { - $manager = $this->getManager(); - - if ($manager !== null) { - $manager->send($contentType, $data, $code); - } - } - /** - * Sends a JSON response to the client. - * - * The basic format of the message will be as follows: - *

    - * {
    - *   "message":"Action is not set.",
    - *   "type":"error"
    - *   "http-code":404
    - *   "more-info":EXTRA_INFO
    - * } - *

    - * Where EXTRA_INFO can be a simple string or any JSON data. - * - * @param string $message The message to send back. - * - * @param string $type A string that tells the client what is the type of - * the message. The developer can specify his own message types such as - * 'debug', 'info' or any string. If it is empty string, it will be not - * included in response payload. - * - * @param int $code Response code (such as 404 or 200). Default is 200. - * - * @param mixed $otherInfo Any other data to send back it can be a simple - * string, an object... . If null is given, the parameter 'more-info' - * will be not included in response. Default is empty string. Default is null. - * - */ - public function sendResponse(string $message, int $code = 200, string $type = '', mixed $otherInfo = '') { - $manager = $this->getManager(); - - if ($manager !== null) { - $manager->sendResponse($message, $code, $type, $otherInfo); - } - } - /** - * Sets the description of the service. - * - * Used to help front-end to identify the use of the service. - * - * @param string $desc Action description. - * - */ - public final function setDescription(string $desc) { - $this->serviceDesc = trim($desc); - } - /** - * Sets the value of the property 'requireAuth'. - * - * The property is used to tell if the authorization step will be skipped - * or not when the service is called. - * - * @param bool $bool True to make authorization step required. False to - * skip the authorization step. - * - */ - public function setIsAuthRequired(bool $bool) { - $this->requireAuth = $bool; - } - /** - * Associate the web service with a manager. - * - * The developer does not have to use this method. It is used when a - * service is added to a manager. - * - * @param WebServicesManager|null $manager The manager at which the service - * will be associated with. If null is given, the association will be removed if - * the service was associated with a manager. - * - */ - public function setManager(?WebServicesManager $manager) { - if ($manager === null) { - $this->owner = null; - } else { - $this->owner = $manager; - } - } - /** - * Sets the name of the service. - * - * A valid service name must follow the following rules: - * - * - * @param string $name The name of the web service. - * - * @return bool If the given name is valid, the method will return - * true once the name is set. false is returned if the given - * name is invalid. - * - */ - public final function setName(string $name) : bool { - if (self::isValidName($name)) { - $this->name = trim($name); - - return true; - } - - return false; - } - /** - * Adds multiple request methods as one group. - * - * @param array $methods - */ - public function setRequestMethods(array $methods) { - foreach ($methods as $m) { - $this->addRequestMethod($m); - } - } - /** - * Sets version number or name at which the service was added to a manager. - * - * This method is called automatically when the service is added to any services manager. - * The developer does not have to use this method. - * - * @param string $sinceAPIv The version number at which the service was added to the API. - * - */ - public final function setSince(string $sinceAPIv) { - $this->sinceVersion = $sinceAPIv; - } - /** - * Returns a Json object that represents the service. - * - * The generated JSON string from the returned Json object will have - * the following format: - *

    - * {
    - *   "name":"",
    - *   "since":"",
    - *   "description":"",
    - *   "request-methods":[],
    - *   "parameters":[],
    - *   "responses":[]
    - * } - *

    - * - * @return Json an object of type Json. - * - */ - public function toJSON() : Json { - $json = new Json(); - $json->add('name', $this->getName()); - $json->add('since', $this->getSince()); - $json->add('description', $this->getDescription()); - $json->add('request-methods', $this->reqMethods); - $json->add('parameters', $this->parameters); - $json->add('responses', $this->getResponsesDescriptions()); - - return $json; - } +abstract class AbstractWebService extends WebService { } diff --git a/WebFiori/Http/Annotations/AllowAnonymous.php b/WebFiori/Http/Annotations/AllowAnonymous.php new file mode 100644 index 0000000..0694f3a --- /dev/null +++ b/WebFiori/Http/Annotations/AllowAnonymous.php @@ -0,0 +1,8 @@ +statusCode = $statusCode; + $this->responseType = $responseType; + } + + public function getStatusCode(): int { + return $this->statusCode; + } + + public function getResponseType(): string { + return $this->responseType; + } +} diff --git a/WebFiori/Http/Exceptions/NotFoundException.php b/WebFiori/Http/Exceptions/NotFoundException.php new file mode 100644 index 0000000..f236d60 --- /dev/null +++ b/WebFiori/Http/Exceptions/NotFoundException.php @@ -0,0 +1,14 @@ +setDescription('Returns a JSON string that contains all ' .'needed information about all end points which are registered ' .'under given manager.'); - $this->addParameter([ - ParamOption::NAME => 'version', - ParamOption::TYPE => ParamType::STRING, - ParamOption::OPTIONAL => true, - ParamOption::DESCRIPTION => 'Optional parameter. ' - .'If set, the information that will be returned will be specific ' - .'to the given version number.' - ]); - $this->setRequestMethods(RequestMethod::GET, RequestMethod::POST); + $this->addRequestMethod(RequestMethod::GET); } /** * Sends back JSON response that contains information about the services diff --git a/WebFiori/Http/ObjectMapper.php b/WebFiori/Http/ObjectMapper.php index 4ab7380..bdb46ad 100644 --- a/WebFiori/Http/ObjectMapper.php +++ b/WebFiori/Http/ObjectMapper.php @@ -39,12 +39,12 @@ class ObjectMapper { * @param string $clazz The name of the class that API request will be mapped * to. Usually obtained using the syntax 'Class::class'. * - * @param AbstractWebService $service The service at which its parameters + * @param WebService $service The service at which its parameters * will be mapped to the object. * * @throws Exception */ - public function __construct(string $clazz, AbstractWebService $service) { + public function __construct(string $clazz, WebService $service) { $this->settersMap = []; $this->setClass($clazz); $this->extractMethodsNames($service->getInputs()); diff --git a/WebFiori/Http/OpenAPI/APIResponseDefinition.php b/WebFiori/Http/OpenAPI/APIResponseDefinition.php new file mode 100644 index 0000000..6c28712 --- /dev/null +++ b/WebFiori/Http/OpenAPI/APIResponseDefinition.php @@ -0,0 +1,68 @@ +statusCode = $statusCode; + $this->description = $description; + } + + /** + * Gets the status code. + * + * @return string + */ + public function getStatusCode(): string { + return $this->statusCode; + } + + /** + * Adds content for a specific media type. + * + * @param string $mediaType Media type (e.g., 'application/json') + * @param Schema $schema The schema for this content type + * + * @return ContentType The created content type object + */ + public function addContent(string $mediaType, Schema $schema): ContentType { + $content = new ContentType($mediaType, $schema); + $this->content[$mediaType] = $content; + return $content; + } + + /** + * Converts the response to JSON representation. + * + * @return Json JSON object + */ + public function toJson(): Json { + $json = new Json(['description' => $this->description]); + + if (!empty($this->content)) { + $contentJson = new Json(); + foreach ($this->content as $mediaType => $contentType) { + $contentJson->add($mediaType, $contentType->toJson()); + } + $json->add('content', $contentJson); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/ComponentsObj.php b/WebFiori/Http/OpenAPI/ComponentsObj.php new file mode 100644 index 0000000..93801c4 --- /dev/null +++ b/WebFiori/Http/OpenAPI/ComponentsObj.php @@ -0,0 +1,101 @@ +schemas[$name] = $schema; + return $this; + } + + /** + * Adds a reusable Security Scheme Object to the components. + * + * The key MUST match the regular expression: ^[a-zA-Z0-9\.\-_]+$ + * + * @param string $name The name/key for the security scheme (e.g., "bearerAuth", "apiKey"). + * @param SecuritySchemeObj $scheme The Security Scheme Object. + * + * @return ComponentsObj Returns self for method chaining. + */ + public function addSecurityScheme(string $name, SecuritySchemeObj $scheme): ComponentsObj { + $this->securitySchemes[$name] = $scheme; + return $this; + } + + /** + * Returns all schemas. + * + * @return array Map of schema names to schema definitions. + */ + public function getSchemas(): array { + return $this->schemas; + } + + /** + * Returns all security schemes. + * + * @return array Map of security scheme names to SecuritySchemeObj. + */ + public function getSecuritySchemes(): array { + return $this->securitySchemes; + } + + /** + * Returns a Json object that represents the Components Object. + * + * The JSON structure follows the OpenAPI 3.1.0 specification. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json(); + + if (!empty($this->schemas)) { + $json->add('schemas', $this->schemas); + } + + if (!empty($this->securitySchemes)) { + $json->add('securitySchemes', $this->securitySchemes); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/ContactObj.php b/WebFiori/Http/OpenAPI/ContactObj.php new file mode 100644 index 0000000..06ddfe6 --- /dev/null +++ b/WebFiori/Http/OpenAPI/ContactObj.php @@ -0,0 +1,127 @@ +name = $name; + return $this; + } + + /** + * Returns the contact name. + * + * @return string|null Returns the value, or null if not set. + */ + public function getName(): ?string { + return $this->name; + } + + /** + * Sets the URI for the contact information. + * + * @param string $url The URI for the contact information. This MUST be in the form of a URI. + * + * @return ContactObj Returns self for method chaining. + */ + public function setUrl(string $url): ContactObj { + $this->url = $url; + return $this; + } + + /** + * Returns the contact URL. + * + * @return string|null Returns the value, or null if not set. + */ + public function getUrl(): ?string { + return $this->url; + } + + /** + * Sets the email address of the contact person/organization. + * + * @param string $email The email address. This MUST be in the form of an email address. + * + * @return ContactObj Returns self for method chaining. + */ + public function setEmail(string $email): ContactObj { + $this->email = $email; + return $this; + } + + /** + * Returns the contact email. + * + * @return string|null Returns the value, or null if not set. + */ + public function getEmail(): ?string { + return $this->email; + } + + /** + * Returns a Json object that represents the Contact Object. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json(); + + if ($this->getName() !== null) { + $json->add('name', $this->getName()); + } + + if ($this->getUrl() !== null) { + $json->add('url', $this->getUrl()); + } + + if ($this->getEmail() !== null) { + $json->add('email', $this->getEmail()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/ContentType.php b/WebFiori/Http/OpenAPI/ContentType.php new file mode 100644 index 0000000..4da2c89 --- /dev/null +++ b/WebFiori/Http/OpenAPI/ContentType.php @@ -0,0 +1,52 @@ +mediaType = $mediaType; + $this->schema = $schema; + } + + /** + * Gets the media type. + * + * @return string + */ + public function getMediaType(): string { + return $this->mediaType; + } + + /** + * Gets the schema. + * + * @return Schema + */ + public function getSchema(): Schema { + return $this->schema; + } + + /** + * Converts the content type to JSON representation. + * + * @return Json JSON object + */ + public function toJson(): Json { + return new Json(['schema' => $this->schema->toJson()]); + } +} diff --git a/WebFiori/Http/OpenAPI/ExternalDocObj.php b/WebFiori/Http/OpenAPI/ExternalDocObj.php new file mode 100644 index 0000000..43c2193 --- /dev/null +++ b/WebFiori/Http/OpenAPI/ExternalDocObj.php @@ -0,0 +1,113 @@ +setUrl($url); + + if ($description !== null) { + $this->setDescription($description); + } + } + + /** + * Sets the description of the target documentation. + * + * CommonMark syntax MAY be used for rich text representation. + * + * @param string $description A description of the target documentation. + * + * @return ExternalDocObj Returns self for method chaining. + */ + public function setDescription(string $description): ExternalDocObj { + $this->description = $description; + return $this; + } + + /** + * Returns the description of the target documentation. + * + * @return string|null The description, or null if not set. + */ + public function getDescription(): ?string { + return $this->description; + } + + /** + * Sets the URI for the target documentation. + * + * This MUST be in the form of a URI. + * + * @param string $url The URI for the target documentation. + * + * @return ExternalDocObj Returns self for method chaining. + */ + public function setUrl(string $url): ExternalDocObj { + $this->url = $url; + return $this; + } + + /** + * Returns the URI for the target documentation. + * + * @return string The URI for the target documentation. + */ + public function getUrl(): string { + return $this->url; + } + + /** + * Returns a Json object that represents the External Documentation Object. + * + * The JSON structure follows the OpenAPI 3.1.0 specification. + * + * @return Json A Json object representation of this External Documentation Object. + */ + public function toJSON(): Json { + $json = new Json([ + 'url' => $this->getUrl() + ]); + + if ($this->getDescription() !== null) { + $json->add('description', $this->getDescription()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/HeaderObj.php b/WebFiori/Http/OpenAPI/HeaderObj.php new file mode 100644 index 0000000..6a69678 --- /dev/null +++ b/WebFiori/Http/OpenAPI/HeaderObj.php @@ -0,0 +1,309 @@ +description = $description; + return $this; + } + + /** + * Returns the description. + * + * @return string|null Returns the value, or null if not set. + */ + public function getDescription(): ?string { + return $this->description; + } + + /** + * Sets whether this header is mandatory. + * + * @param bool $required True if required. + * + * @return HeaderObj Returns self for method chaining. + */ + public function setRequired(bool $required): HeaderObj { + $this->required = $required; + return $this; + } + + /** + * Returns whether this header is required. + * + * @return bool + */ + public function isRequired(): bool { + return $this->required; + } + + public function getRequired(): bool { + return $this->required; + } + + /** + * Sets whether this header is deprecated. + * + * @param bool $deprecated True if deprecated. + * + * @return HeaderObj Returns self for method chaining. + */ + public function setDeprecated(bool $deprecated): HeaderObj { + $this->deprecated = $deprecated; + return $this; + } + + /** + * Returns whether this header is deprecated. + * + * @return bool + */ + public function isDeprecated(): bool { + return $this->deprecated; + } + + public function getDeprecated(): bool { + return $this->deprecated; + } + + /** + * Sets the serialization style. + * + * @param string $style The style value. Default is "simple". + * + * @return HeaderObj Returns self for method chaining. + */ + public function setStyle(string $style): HeaderObj { + $this->style = $style; + return $this; + } + + /** + * Returns the style. + * + * @return string|null Returns the value, or null if not set. + */ + public function getStyle(): ?string { + return $this->style; + } + + /** + * Sets the explode value. + * + * @param bool $explode The explode value. + * + * @return HeaderObj Returns self for method chaining. + */ + public function setExplode(bool $explode): HeaderObj { + $this->explode = $explode; + return $this; + } + + /** + * Returns the explode value. + * + * @return bool|null Returns the value, or null if not set. + */ + public function getExplode(): ?bool { + return $this->explode; + } + + /** + * Sets the schema defining the type used for the header. + * + * @param mixed $schema Schema Object or any schema definition. + * + * @return HeaderObj Returns self for method chaining. + */ + public function setSchema($schema): HeaderObj { + $this->schema = $schema; + return $this; + } + + /** + * Returns the schema. + * + * @return mixed + */ + public function getSchema() { + return $this->schema; + } + + /** + * Sets an example of the header's potential value. + * + * @param mixed $example Example value. + * + * @return HeaderObj Returns self for method chaining. + */ + public function setExample($example): HeaderObj { + $this->example = $example; + return $this; + } + + /** + * Returns the example. + * + * @return mixed + */ + public function getExample() { + return $this->example; + } + + /** + * Sets examples of the header's potential value. + * + * @param array $examples Map of example names to Example Objects or Reference Objects. + * + * @return HeaderObj Returns self for method chaining. + */ + public function setExamples(array $examples): HeaderObj { + $this->examples = $examples; + return $this; + } + + /** + * Returns the examples. + * + * @return array|null Returns the value, or null if not set. + */ + public function getExamples(): ?array { + return $this->examples; + } + + /** + * Returns a Json object that represents the Header Object. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json(); + + if ($this->getDescription() !== null) { + $json->add('description', $this->getDescription()); + } + + if ($this->getRequired()) { + $json->add('required', $this->getRequired()); + } + + if ($this->getDeprecated()) { + $json->add('deprecated', $this->getDeprecated()); + } + + if ($this->getStyle() !== null) { + $json->add('style', $this->getStyle()); + } + + if ($this->getExplode() !== null) { + $json->add('explode', $this->getExplode()); + } + + if ($this->getSchema() !== null) { + $json->add('schema', $this->getSchema()); + } + + if ($this->getExample() !== null) { + $json->add('example', $this->getExample()); + } + + if ($this->getExamples() !== null) { + $json->add('examples', $this->getExamples()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/InfoObj.php b/WebFiori/Http/OpenAPI/InfoObj.php new file mode 100644 index 0000000..2e886ef --- /dev/null +++ b/WebFiori/Http/OpenAPI/InfoObj.php @@ -0,0 +1,272 @@ +setTitle($title); + $this->setVersion($version); + } + + /** + * Sets the title of the API. + * + * @param string $title The title of the API. + * + * @return InfoObj Returns self for method chaining. + */ + public function setTitle(string $title): InfoObj { + $this->title = $title; + return $this; + } + + /** + * Returns the title of the API. + * + * @return string + */ + public function getTitle(): string { + return $this->title; + } + + /** + * Sets the version of the OpenAPI Document. + * + * @param string $version The version of the OpenAPI Document. + * + * @return InfoObj Returns self for method chaining. + */ + public function setVersion(string $version): InfoObj { + $this->version = $version; + return $this; + } + + /** + * Returns the version of the OpenAPI Document. + * + * @return string + */ + public function getVersion(): string { + return $this->version; + } + + /** + * Sets a short summary of the API. + * + * @param string $summary A short summary of the API. + * + * @return InfoObj Returns self for method chaining. + */ + public function setSummary(string $summary): InfoObj { + $this->summary = $summary; + return $this; + } + + /** + * Returns the summary of the API. + * + * @return string|null Returns the value, or null if not set. + */ + public function getSummary(): ?string { + return $this->summary; + } + + /** + * Sets a description of the API. + * + * @param string $description A description of the API. + * CommonMark syntax MAY be used for rich text representation. + * + * @return InfoObj Returns self for method chaining. + */ + public function setDescription(string $description): InfoObj { + $this->description = $description; + return $this; + } + + /** + * Returns the description of the API. + * + * @return string|null Returns the value, or null if not set. + */ + public function getDescription(): ?string { + return $this->description; + } + + /** + * Sets a URI for the Terms of Service for the API. + * + * @param string $termsOfService A URI for the Terms of Service. This MUST be in the form of a URI. + * + * @return InfoObj Returns self for method chaining. + */ + public function setTermsOfService(string $termsOfService): InfoObj { + $this->termsOfService = $termsOfService; + return $this; + } + + /** + * Returns the Terms of Service URI. + * + * @return string|null Returns the value, or null if not set. + */ + public function getTermsOfService(): ?string { + return $this->termsOfService; + } + + /** + * Sets the contact information for the exposed API. + * + * @param ContactObj $contact Contact Object. + * + * @return InfoObj Returns self for method chaining. + */ + public function setContact(ContactObj $contact): InfoObj { + $this->contact = $contact; + return $this; + } + + /** + * Returns the contact information. + * + * @return ContactObj|null Returns the value, or null if not set. + */ + public function getContact(): ?ContactObj { + return $this->contact; + } + + /** + * Sets the license information for the exposed API. + * + * @param LicenseObj $license License Object. + * + * @return InfoObj Returns self for method chaining. + */ + public function setLicense(LicenseObj $license): InfoObj { + $this->license = $license; + return $this; + } + + /** + * Returns the license information. + * + * @return LicenseObj|null Returns the value, or null if not set. + */ + public function getLicense(): ?LicenseObj { + return $this->license; + } + + /** + * Returns a Json object that represents the Info Object. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json([ + 'title' => $this->getTitle(), + 'version' => $this->getVersion() + ]); + + + if ($this->getSummary() !== null) { + $json->add('summary', $this->getSummary()); + } + + if ($this->getDescription() !== null) { + $json->add('description', $this->getDescription()); + } + + if ($this->getTermsOfService() !== null) { + $json->add('termsOfService', $this->getTermsOfService()); + } + + if ($this->getContact() !== null) { + $json->add('contact', $this->getContact()); + } + + if ($this->getLicense() !== null) { + $json->add('license', $this->getLicense()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/LicenseObj.php b/WebFiori/Http/OpenAPI/LicenseObj.php new file mode 100644 index 0000000..72236ce --- /dev/null +++ b/WebFiori/Http/OpenAPI/LicenseObj.php @@ -0,0 +1,142 @@ +setName($name); + } + + /** + * Sets the license name used for the API. + * + * @param string $name The license name. + * + * @return LicenseObj Returns self for method chaining. + */ + public function setName(string $name): LicenseObj { + $this->name = $name; + return $this; + } + + /** + * Returns the license name. + * + * @return string + */ + public function getName(): string { + return $this->name; + } + + /** + * Sets an SPDX license expression for the API. + * + * @param string $identifier An SPDX license expression. + * The identifier field is mutually exclusive of the url field. + * + * @return LicenseObj Returns self for method chaining. + */ + public function setIdentifier(string $identifier): LicenseObj { + $this->identifier = $identifier; + $this->url = null; + return $this; + } + + /** + * Returns the SPDX license identifier. + * + * @return string|null Returns the value, or null if not set. + */ + public function getIdentifier(): ?string { + return $this->identifier; + } + + /** + * Sets a URI for the license used for the API. + * + * @param string $url A URI for the license. This MUST be in the form of a URI. + * The url field is mutually exclusive of the identifier field. + * + * @return LicenseObj Returns self for method chaining. + */ + public function setUrl(string $url): LicenseObj { + $this->url = $url; + $this->identifier = null; + return $this; + } + + /** + * Returns the license URL. + * + * @return string|null Returns the value, or null if not set. + */ + public function getUrl(): ?string { + return $this->url; + } + + /** + * Returns a Json object that represents the License Object. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json([ + 'name' => $this->getName() + ]); + + if ($this->getIdentifier() !== null) { + $json->add('identifier', $this->getIdentifier()); + } + + if ($this->getUrl() !== null) { + $json->add('url', $this->getUrl()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/MediaTypeObj.php b/WebFiori/Http/OpenAPI/MediaTypeObj.php new file mode 100644 index 0000000..c6a3356 --- /dev/null +++ b/WebFiori/Http/OpenAPI/MediaTypeObj.php @@ -0,0 +1,42 @@ +schema = $schema; + return $this; + } + + public function getSchema() { + return $this->schema; + } + + public function toJSON(): Json { + $json = new Json(); + + if ($this->getSchema() !== null) { + $json->add('schema', $this->getSchema()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/OAuthFlowObj.php b/WebFiori/Http/OpenAPI/OAuthFlowObj.php new file mode 100644 index 0000000..c7b8192 --- /dev/null +++ b/WebFiori/Http/OpenAPI/OAuthFlowObj.php @@ -0,0 +1,192 @@ +scopes = $scopes; + } + + /** + * Sets the authorization URL to be used for this flow. + * + * @param string $authorizationUrl The authorization URL. This MUST be in the form of a URL. + * REQUIRED for implicit and authorizationCode flows. + * + * @return OAuthFlowObj Returns self for method chaining. + */ + public function setAuthorizationUrl(string $authorizationUrl): OAuthFlowObj { + $this->authorizationUrl = $authorizationUrl; + return $this; + } + + /** + * Returns the authorization URL. + * + * @return string|null Returns the value, or null if not set. + */ + public function getAuthorizationUrl(): ?string { + return $this->authorizationUrl; + } + + /** + * Sets the token URL to be used for this flow. + * + * @param string $tokenUrl The token URL. This MUST be in the form of a URL. + * REQUIRED for password, clientCredentials, and authorizationCode flows. + * + * @return OAuthFlowObj Returns self for method chaining. + */ + public function setTokenUrl(string $tokenUrl): OAuthFlowObj { + $this->tokenUrl = $tokenUrl; + return $this; + } + + /** + * Returns the token URL. + * + * @return string|null Returns the value, or null if not set. + */ + public function getTokenUrl(): ?string { + return $this->tokenUrl; + } + + /** + * Sets the URL to be used for obtaining refresh tokens. + * + * @param string $refreshUrl The refresh URL. This MUST be in the form of a URL. + * + * @return OAuthFlowObj Returns self for method chaining. + */ + public function setRefreshUrl(string $refreshUrl): OAuthFlowObj { + $this->refreshUrl = $refreshUrl; + return $this; + } + + /** + * Returns the refresh URL. + * + * @return string|null Returns the value, or null if not set. + */ + public function getRefreshUrl(): ?string { + return $this->refreshUrl; + } + + /** + * Sets the available scopes for the OAuth2 security scheme. + * + * @param array $scopes A map between the scope name and a short description for it. + * + * @return OAuthFlowObj Returns self for method chaining. + */ + public function setScopes(array $scopes): OAuthFlowObj { + $this->scopes = $scopes; + return $this; + } + + /** + * Adds a scope to the OAuth2 security scheme. + * + * @param string $name The scope name. + * @param string $description A short description for the scope. + * + * @return OAuthFlowObj + */ + public function addScope(string $name, string $description): OAuthFlowObj { + $this->scopes[$name] = $description; + return $this; + } + + /** + * Returns the available scopes. + * + * @return array + */ + public function getScopes(): array { + return $this->scopes; + } + + /** + * Returns a Json object that represents the OAuth Flow Object. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json([ + 'scopes' => $this->getScopes() + ]); + + if ($this->getAuthorizationUrl() !== null) { + $json->add('authorizationUrl', $this->getAuthorizationUrl()); + } + + if ($this->getTokenUrl() !== null) { + $json->add('tokenUrl', $this->getTokenUrl()); + } + + if ($this->getRefreshUrl() !== null) { + $json->add('refreshUrl', $this->getRefreshUrl()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/OAuthFlowsObj.php b/WebFiori/Http/OpenAPI/OAuthFlowsObj.php new file mode 100644 index 0000000..269ab3d --- /dev/null +++ b/WebFiori/Http/OpenAPI/OAuthFlowsObj.php @@ -0,0 +1,159 @@ +implicit = $implicit; + return $this; + } + + /** + * Returns the implicit flow configuration. + * + * @return OAuthFlowObj|null Returns the value, or null if not set. + */ + public function getImplicit(): ?OAuthFlowObj { + return $this->implicit; + } + + /** + * Sets configuration for the OAuth Resource Owner Password flow. + * + * @param OAuthFlowObj $password OAuth Flow Object for password flow. + * + * @return OAuthFlowsObj Returns self for method chaining. + */ + public function setPassword(OAuthFlowObj $password): OAuthFlowsObj { + $this->password = $password; + return $this; + } + + /** + * Returns the password flow configuration. + * + * @return OAuthFlowObj|null Returns the value, or null if not set. + */ + public function getPassword(): ?OAuthFlowObj { + return $this->password; + } + + /** + * Sets configuration for the OAuth Client Credentials flow. + * + * @param OAuthFlowObj $clientCredentials OAuth Flow Object for client credentials flow. + * + * @return OAuthFlowsObj Returns self for method chaining. + */ + public function setClientCredentials(OAuthFlowObj $clientCredentials): OAuthFlowsObj { + $this->clientCredentials = $clientCredentials; + return $this; + } + + /** + * Returns the client credentials flow configuration. + * + * @return OAuthFlowObj|null Returns the value, or null if not set. + */ + public function getClientCredentials(): ?OAuthFlowObj { + return $this->clientCredentials; + } + + /** + * Sets configuration for the OAuth Authorization Code flow. + * + * @param OAuthFlowObj $authorizationCode OAuth Flow Object for authorization code flow. + * + * @return OAuthFlowsObj Returns self for method chaining. + */ + public function setAuthorizationCode(OAuthFlowObj $authorizationCode): OAuthFlowsObj { + $this->authorizationCode = $authorizationCode; + return $this; + } + + /** + * Returns the authorization code flow configuration. + * + * @return OAuthFlowObj|null Returns the value, or null if not set. + */ + public function getAuthorizationCode(): ?OAuthFlowObj { + return $this->authorizationCode; + } + + /** + * Returns a Json object that represents the OAuth Flows Object. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json(); + + if ($this->getImplicit() !== null) { + $json->add('implicit', $this->getImplicit()); + } + + if ($this->getPassword() !== null) { + $json->add('password', $this->getPassword()); + } + + if ($this->getClientCredentials() !== null) { + $json->add('clientCredentials', $this->getClientCredentials()); + } + + if ($this->getAuthorizationCode() !== null) { + $json->add('authorizationCode', $this->getAuthorizationCode()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/OpenAPIObj.php b/WebFiori/Http/OpenAPI/OpenAPIObj.php new file mode 100644 index 0000000..587b297 --- /dev/null +++ b/WebFiori/Http/OpenAPI/OpenAPIObj.php @@ -0,0 +1,146 @@ +info = $info; + $this->openapi = $openapi; + } + + /** + * Sets the OpenAPI Specification version number. + * + * This string MUST be the version number of the OpenAPI Specification + * that the OpenAPI Document uses (e.g., "3.1.0"). + * + * @param string $openapi The OpenAPI Specification version. + * + * @return OpenAPIObj Returns self for method chaining. + */ + public function setOpenapi(string $openapi): OpenAPIObj { + $this->openapi = $openapi; + return $this; + } + + /** + * Returns the OpenAPI Specification version number. + * + * @return string The OpenAPI Specification version. + */ + public function getOpenapi(): string { + return $this->openapi; + } + + /** + * Sets the Info Object containing API metadata. + * + * @param InfoObj $info The Info Object with API metadata. + * + * @return OpenAPIObj Returns self for method chaining. + */ + public function setInfo(InfoObj $info): OpenAPIObj { + $this->info = $info; + return $this; + } + + /** + * Returns the Info Object containing API metadata. + * + * @return InfoObj The Info Object. + */ + public function getInfo(): InfoObj { + return $this->info; + } + + /** + * Sets the Paths Object containing API paths and operations. + * + * @param PathsObj $paths The Paths Object. + * + * @return OpenAPIObj Returns self for method chaining. + */ + public function setPaths(PathsObj $paths): OpenAPIObj { + $this->paths = $paths; + return $this; + } + + /** + * Returns the Paths Object containing API paths and operations. + * + * @return PathsObj|null The Paths Object or null if not set. + */ + public function getPaths(): ?PathsObj { + return $this->paths; + } + + /** + * Returns a Json object that represents the OpenAPI Object. + * + * The JSON structure follows the OpenAPI 3.1.0 specification and represents + * a complete OpenAPI Description document. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json([ + 'openapi' => $this->getOpenapi(), + 'info' => $this->getInfo() + ]); + + if ($this->getPaths() !== null) { + $json->add('paths', $this->getPaths()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/OperationObj.php b/WebFiori/Http/OpenAPI/OperationObj.php new file mode 100644 index 0000000..c713377 --- /dev/null +++ b/WebFiori/Http/OpenAPI/OperationObj.php @@ -0,0 +1,46 @@ +responses = $responses; + } + + public function setResponses(ResponsesObj $responses): OperationObj { + $this->responses = $responses; + return $this; + } + + public function getResponses(): ResponsesObj { + return $this->responses; + } + + public function toJSON(): Json { + $json = new Json([ + 'responses' => $this->getResponses() + ]); + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/ParameterObj.php b/WebFiori/Http/OpenAPI/ParameterObj.php new file mode 100644 index 0000000..741d71b --- /dev/null +++ b/WebFiori/Http/OpenAPI/ParameterObj.php @@ -0,0 +1,480 @@ +setName($name); + $this->setIn($in); + } + + /** + * Sets the name of the parameter. + * + * @param string $name The name of the parameter. + * + * @return ParameterObj Returns self for method chaining. + */ + public function setName(string $name): ParameterObj { + $this->name = $name; + return $this; + } + + /** + * Returns the name of the parameter. + * + * @return string + */ + public function getName(): string { + return $this->name; + } + + /** + * Sets the location of the parameter. + * + * @param string $in The location. Possible values: "query", "header", "path", "cookie". + * + * @return ParameterObj Returns self for method chaining. + */ + public function setIn(string $in): ParameterObj { + $this->in = $in; + if ($in === 'path') { + $this->required = true; + } + return $this; + } + + /** + * Returns the location of the parameter. + * + * @return string + */ + public function getIn(): string { + return $this->in; + } + + /** + * Sets the description of the parameter. + * + * @param string $description A brief description of the parameter. + * + * @return ParameterObj Returns self for method chaining. + */ + public function setDescription(string $description): ParameterObj { + $this->description = $description; + return $this; + } + + /** + * Returns the description. + * + * @return string|null Returns the value, or null if not set. + */ + public function getDescription(): ?string { + return $this->description; + } + + /** + * Sets whether this parameter is mandatory. + * + * @param bool $required True if required. + * + * @return ParameterObj Returns self for method chaining. + */ + public function setRequired(bool $required): ParameterObj { + $this->required = $required; + return $this; + } + + /** + * Returns whether this parameter is required. + * + * @return bool + */ + public function isRequired(): bool { + return $this->required; + } + + /** + * Returns whether this parameter is required. + * + * Alias for isRequired() for consistency with toJSON(). + * + * @return bool + */ + public function getRequired(): bool { + return $this->required; + } + + /** + * Sets whether this parameter is deprecated. + * + * @param bool $deprecated True if deprecated. + * + * @return ParameterObj Returns self for method chaining. + */ + public function setDeprecated(bool $deprecated): ParameterObj { + $this->deprecated = $deprecated; + return $this; + } + + /** + * Returns whether this parameter is deprecated. + * + * @return bool + */ + public function isDeprecated(): bool { + return $this->deprecated; + } + + /** + * Returns whether this parameter is deprecated. + * + * Alias for isDeprecated() for consistency with toJSON(). + * + * @return bool + */ + public function getDeprecated(): bool { + return $this->deprecated; + } + + /** + * Sets whether to allow empty value. + * + * @param bool $allowEmptyValue True to allow empty value. + * + * @return ParameterObj Returns self for method chaining. + */ + public function setAllowEmptyValue(bool $allowEmptyValue): ParameterObj { + $this->allowEmptyValue = $allowEmptyValue; + return $this; + } + + /** + * Returns whether empty value is allowed. + * + * @return bool + */ + public function isAllowEmptyValue(): bool { + return $this->allowEmptyValue; + } + + public function getAllowEmptyValue(): bool { + return $this->allowEmptyValue; + } + + /** + * Sets the serialization style. + * + * @param string $style The style value. + * + * @return ParameterObj Returns self for method chaining. + */ + public function setStyle(string $style): ParameterObj { + $this->style = $style; + return $this; + } + + /** + * Returns the style. + * + * @return string|null Returns the value, or null if not set. + */ + public function getStyle(): ?string { + return $this->style; + } + + /** + * Sets the explode value. + * + * @param bool $explode The explode value. + * + * @return ParameterObj Returns self for method chaining. + */ + public function setExplode(bool $explode): ParameterObj { + $this->explode = $explode; + return $this; + } + + /** + * Returns the explode value. + * + * @return bool|null Returns the value, or null if not set. + */ + public function getExplode(): ?bool { + return $this->explode; + } + + /** + * Sets whether to allow reserved characters. + * + * @param bool $allowReserved True to allow reserved characters. + * + * @return ParameterObj Returns self for method chaining. + */ + public function setAllowReserved(bool $allowReserved): ParameterObj { + $this->allowReserved = $allowReserved; + return $this; + } + + /** + * Returns whether reserved characters are allowed. + * + * @return bool|null Returns the value, or null if not set. + */ + public function getAllowReserved(): ?bool { + return $this->allowReserved; + } + + /** + * Sets the schema defining the type used for the parameter. + * + * @param mixed $schema Schema Object or any schema definition. + * + * @return ParameterObj Returns self for method chaining. + */ + public function setSchema($schema): ParameterObj { + $this->schema = $schema; + return $this; + } + + /** + * Returns the schema. + * + * @return mixed + */ + public function getSchema() { + return $this->schema; + } + + /** + * Sets an example of the parameter's potential value. + * + * @param mixed $example Example value. + * + * @return ParameterObj Returns self for method chaining. + */ + public function setExample($example): ParameterObj { + $this->example = $example; + return $this; + } + + /** + * Returns the example. + * + * @return mixed + */ + public function getExample() { + return $this->example; + } + + /** + * Sets examples of the parameter's potential value. + * + * @param array $examples Map of example names to Example Objects or Reference Objects. + * + * @return ParameterObj Returns self for method chaining. + */ + public function setExamples(array $examples): ParameterObj { + $this->examples = $examples; + return $this; + } + + /** + * Returns the examples. + * + * @return array|null Returns the value, or null if not set. + */ + public function getExamples(): ?array { + return $this->examples; + } + + /** + * Returns a Json object that represents the Parameter Object. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json([ + 'name' => $this->getName(), + 'in' => $this->getIn() + ]); + + if ($this->getDescription() !== null) { + $json->add('description', $this->getDescription()); + } + + if ($this->getRequired()) { + $json->add('required', $this->getRequired()); + } + + if ($this->getDeprecated()) { + $json->add('deprecated', $this->getDeprecated()); + } + + if ($this->getAllowEmptyValue()) { + $json->add('allowEmptyValue', $this->getAllowEmptyValue()); + } + + if ($this->getStyle() !== null) { + $json->add('style', $this->getStyle()); + } + + if ($this->getExplode() !== null) { + $json->add('explode', $this->getExplode()); + } + + if ($this->getAllowReserved() !== null) { + $json->add('allowReserved', $this->getAllowReserved()); + } + + if ($this->getSchema() !== null) { + $json->add('schema', $this->getSchema()); + } + + if ($this->getExample() !== null) { + $json->add('example', $this->getExample()); + } + + if ($this->getExamples() !== null) { + $json->add('examples', $this->getExamples()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/PathItemObj.php b/WebFiori/Http/OpenAPI/PathItemObj.php new file mode 100644 index 0000000..dd2eda7 --- /dev/null +++ b/WebFiori/Http/OpenAPI/PathItemObj.php @@ -0,0 +1,123 @@ +get = $operation; + return $this; + } + + public function getGet(): ?OperationObj { + return $this->get; + } + + public function setPost(OperationObj $operation): PathItemObj { + $this->post = $operation; + return $this; + } + + public function getPost(): ?OperationObj { + return $this->post; + } + + public function setPut(OperationObj $operation): PathItemObj { + $this->put = $operation; + return $this; + } + + public function getPut(): ?OperationObj { + return $this->put; + } + + public function setDelete(OperationObj $operation): PathItemObj { + $this->delete = $operation; + return $this; + } + + public function getDelete(): ?OperationObj { + return $this->delete; + } + + public function setPatch(OperationObj $operation): PathItemObj { + $this->patch = $operation; + return $this; + } + + public function getPatch(): ?OperationObj { + return $this->patch; + } + + public function toJSON(): Json { + $json = new Json(); + + if ($this->getGet() !== null) { + $json->add('get', $this->getGet()); + } + + if ($this->getPost() !== null) { + $json->add('post', $this->getPost()); + } + + if ($this->getPut() !== null) { + $json->add('put', $this->getPut()); + } + + if ($this->getDelete() !== null) { + $json->add('delete', $this->getDelete()); + } + + if ($this->getPatch() !== null) { + $json->add('patch', $this->getPatch()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/PathsObj.php b/WebFiori/Http/OpenAPI/PathsObj.php new file mode 100644 index 0000000..0831581 --- /dev/null +++ b/WebFiori/Http/OpenAPI/PathsObj.php @@ -0,0 +1,66 @@ +paths[$path] = $pathItem; + return $this; + } + + /** + * Returns all paths and their operations. + * + * @return array Map of path strings to Path Item Objects. + */ + public function getPaths(): array { + return $this->paths; + } + + /** + * Returns a Json object that represents the Paths Object. + * + * The JSON structure follows the OpenAPI 3.1.0 specification. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json(); + + foreach ($this->paths as $path => $pathItem) { + $json->add($path, $pathItem); + } + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/ReferenceObj.php b/WebFiori/Http/OpenAPI/ReferenceObj.php new file mode 100644 index 0000000..6b05062 --- /dev/null +++ b/WebFiori/Http/OpenAPI/ReferenceObj.php @@ -0,0 +1,144 @@ +setRef($ref); + } + + /** + * Sets the reference identifier. + * + * @param string $ref The reference identifier. This MUST be in the form of a URI. + * + * @return ReferenceObj Returns self for method chaining. + */ + public function setRef(string $ref): ReferenceObj { + $this->ref = $ref; + return $this; + } + + /** + * Returns the reference identifier. + * + * @return string + */ + public function getRef(): string { + return $this->ref; + } + + /** + * Sets a short summary which by default SHOULD override that of the referenced component. + * + * @param string $summary A short summary. + * + * @return ReferenceObj Returns self for method chaining. + */ + public function setSummary(string $summary): ReferenceObj { + $this->summary = $summary; + return $this; + } + + /** + * Returns the summary. + * + * @return string|null Returns the value, or null if not set. + */ + public function getSummary(): ?string { + return $this->summary; + } + + /** + * Sets a description which by default SHOULD override that of the referenced component. + * + * @param string $description A description. + * CommonMark syntax MAY be used for rich text representation. + * + * @return ReferenceObj Returns self for method chaining. + */ + public function setDescription(string $description): ReferenceObj { + $this->description = $description; + return $this; + } + + /** + * Returns the description. + * + * @return string|null Returns the value, or null if not set. + */ + public function getDescription(): ?string { + return $this->description; + } + + /** + * Returns a Json object that represents the Reference Object. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json([ + '$ref' => $this->getRef() + ]); + + if ($this->getSummary() !== null) { + $json->add('summary', $this->getSummary()); + } + + if ($this->getDescription() !== null) { + $json->add('description', $this->getDescription()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/ResponseObj.php b/WebFiori/Http/OpenAPI/ResponseObj.php new file mode 100644 index 0000000..55372e0 --- /dev/null +++ b/WebFiori/Http/OpenAPI/ResponseObj.php @@ -0,0 +1,49 @@ +description = $description; + } + + public function setDescription(string $description): ResponseObj { + $this->description = $description; + return $this; + } + + public function getDescription(): string { + return $this->description; + } + + public function toJSON(): Json { + $json = new Json([ + 'description' => $this->getDescription() + ]); + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/ResponsesObj.php b/WebFiori/Http/OpenAPI/ResponsesObj.php new file mode 100644 index 0000000..70b6f3d --- /dev/null +++ b/WebFiori/Http/OpenAPI/ResponsesObj.php @@ -0,0 +1,72 @@ +responses[$statusCode] = $response; + return $this; + } + + /** + * Returns all responses mapped by status code. + * + * @return array Map of status codes to Response Objects. + */ + public function getResponses(): array { + return $this->responses; + } + + /** + * Returns a Json object that represents the Responses Object. + * + * The JSON structure follows the OpenAPI 3.1.0 specification. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json(); + + foreach ($this->responses as $code => $response) { + $json->add($code, $response); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/Schema.php b/WebFiori/Http/OpenAPI/Schema.php new file mode 100644 index 0000000..1143ef8 --- /dev/null +++ b/WebFiori/Http/OpenAPI/Schema.php @@ -0,0 +1,175 @@ +type = $type; + } + + /** + * Creates a Schema from a RequestParameter. + * + * @param RequestParameter $param The request parameter + * + * @return Schema The schema object + */ + public static function fromRequestParameter(RequestParameter $param): self { + $schema = new self(); + $schema->type = self::mapType($param->getType()); + + // Set format for special types + if ($param->getType() === ParamType::EMAIL) { + $schema->format = 'email'; + } else if ($param->getType() === ParamType::URL) { + $schema->format = 'uri'; + } + + // Constraints + $schema->minimum = $param->getMinValue(); + $schema->maximum = $param->getMaxValue(); + $schema->minLength = $param->getMinLength(); + $schema->maxLength = $param->getMaxLength(); + $schema->default = $param->getDefault(); + $schema->description = $param->getDescription(); + + return $schema; + } + + /** + * Converts the schema to JSON representation. + * + * @return Json JSON object + */ + public function toJson(): Json { + $json = new Json(); + + if ($this->type !== null) { + $json->add('type', $this->type); + } + if ($this->format !== null) { + $json->add('format', $this->format); + } + if ($this->default !== null) { + $json->add('default', $this->default); + } + if ($this->minimum !== null) { + $json->add('minimum', $this->minimum); + } + if ($this->maximum !== null) { + $json->add('maximum', $this->maximum); + } + if ($this->minLength !== null) { + $json->add('minLength', $this->minLength); + } + if ($this->maxLength !== null) { + $json->add('maxLength', $this->maxLength); + } + if ($this->pattern !== null) { + $json->add('pattern', $this->pattern); + } + if ($this->enum !== null) { + $json->add('enum', $this->enum); + } + if (!empty($this->examples)) { + $json->add('examples', $this->examples); + } + + return $json; + } + + /** + * Sets the format. + * + * @param string $format Format (e.g., 'email', 'uri', 'date-time') + * + * @return Schema + */ + public function setFormat(string $format): self { + $this->format = $format; + return $this; + } + + /** + * Sets the pattern (regex). + * + * @param string $pattern Regular expression pattern + * + * @return Schema + */ + public function setPattern(string $pattern): self { + $this->pattern = $pattern; + return $this; + } + + /** + * Sets allowed enum values. + * + * @param array $values Array of allowed values + * + * @return Schema + */ + public function setEnum(array $values): self { + $this->enum = $values; + return $this; + } + + /** + * Adds an example value. + * + * @param mixed $example Example value + * + * @return Schema + */ + public function addExample(mixed $example): self { + $this->examples[] = $example; + return $this; + } + + /** + * Maps internal parameter types to OpenAPI types. + * + * @param string $type Internal type + * + * @return string OpenAPI type + */ + public static function mapType(string $type): string { + $typeMap = [ + ParamType::INT => 'integer', + ParamType::DOUBLE => 'number', + ParamType::STRING => 'string', + ParamType::BOOL => 'boolean', + ParamType::ARR => 'array', + ParamType::EMAIL => 'string', + ParamType::URL => 'string', + ParamType::JSON_OBJ => 'object' + ]; + + return $typeMap[strtolower($type)] ?? 'string'; + } +} diff --git a/WebFiori/Http/OpenAPI/SecurityRequirementObj.php b/WebFiori/Http/OpenAPI/SecurityRequirementObj.php new file mode 100644 index 0000000..7ea2886 --- /dev/null +++ b/WebFiori/Http/OpenAPI/SecurityRequirementObj.php @@ -0,0 +1,73 @@ +requirements[$name] = $scopes; + return $this; + } + + /** + * Returns all security requirements. + * + * @return array Map of security scheme names to scope arrays. + */ + public function getRequirements(): array { + return $this->requirements; + } + + /** + * Returns a Json object that represents the Security Requirement Object. + * + * The JSON structure follows the OpenAPI 3.1.0 specification. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json(); + + foreach ($this->requirements as $name => $scopes) { + $json->add($name, $scopes); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/SecuritySchemeObj.php b/WebFiori/Http/OpenAPI/SecuritySchemeObj.php new file mode 100644 index 0000000..ed1b617 --- /dev/null +++ b/WebFiori/Http/OpenAPI/SecuritySchemeObj.php @@ -0,0 +1,322 @@ +setType($type); + } + + /** + * Sets the type of the security scheme. + * + * @param string $type Valid values are "apiKey", "http", "mutualTLS", "oauth2", "openIdConnect". + * + * @return SecuritySchemeObj Returns self for method chaining. + */ + public function setType(string $type): SecuritySchemeObj { + $this->type = $type; + return $this; + } + + /** + * Returns the type of the security scheme. + * + * @return string + */ + public function getType(): string { + return $this->type; + } + + /** + * Sets the description for security scheme. + * + * @param string $description A description for security scheme. + * + * @return SecuritySchemeObj Returns self for method chaining. + */ + public function setDescription(string $description): SecuritySchemeObj { + $this->description = $description; + return $this; + } + + /** + * Returns the description. + * + * @return string|null Returns the value, or null if not set. + */ + public function getDescription(): ?string { + return $this->description; + } + + /** + * Sets the name of the header, query or cookie parameter to be used. + * + * @param string $name The parameter name. REQUIRED for apiKey type. + * + * @return SecuritySchemeObj Returns self for method chaining. + */ + public function setName(string $name): SecuritySchemeObj { + $this->name = $name; + return $this; + } + + /** + * Returns the parameter name. + * + * @return string|null Returns the value, or null if not set. + */ + public function getName(): ?string { + return $this->name; + } + + /** + * Sets the location of the API key. + * + * @param string $in Valid values are "query", "header", or "cookie". REQUIRED for apiKey type. + * + * @return SecuritySchemeObj Returns self for method chaining. + */ + public function setIn(string $in): SecuritySchemeObj { + $this->in = $in; + return $this; + } + + /** + * Returns the location of the API key. + * + * @return string|null Returns the value, or null if not set. + */ + public function getIn(): ?string { + return $this->in; + } + + /** + * Sets the name of the HTTP Authentication scheme. + * + * @param string $scheme The HTTP Authentication scheme. REQUIRED for http type. + * + * @return SecuritySchemeObj Returns self for method chaining. + */ + public function setScheme(string $scheme): SecuritySchemeObj { + $this->scheme = $scheme; + return $this; + } + + /** + * Returns the HTTP Authentication scheme. + * + * @return string|null Returns the value, or null if not set. + */ + public function getScheme(): ?string { + return $this->scheme; + } + + /** + * Sets a hint to identify how the bearer token is formatted. + * + * @param string $bearerFormat The bearer token format (e.g., "JWT"). + * + * @return SecuritySchemeObj Returns self for method chaining. + */ + public function setBearerFormat(string $bearerFormat): SecuritySchemeObj { + $this->bearerFormat = $bearerFormat; + return $this; + } + + /** + * Returns the bearer token format. + * + * @return string|null Returns the value, or null if not set. + */ + public function getBearerFormat(): ?string { + return $this->bearerFormat; + } + + /** + * Sets configuration information for the OAuth2 flow types supported. + * + * @param OAuthFlowsObj $flows OAuth Flows Object. REQUIRED for oauth2 type. + * + * @return SecuritySchemeObj Returns self for method chaining. + */ + public function setFlows(OAuthFlowsObj $flows): SecuritySchemeObj { + $this->flows = $flows; + return $this; + } + + /** + * Returns the OAuth flows configuration. + * + * @return OAuthFlowsObj|null Returns the value, or null if not set. + */ + public function getFlows(): ?OAuthFlowsObj { + return $this->flows; + } + + /** + * Sets the OpenID Connect discovery URL. + * + * @param string $openIdConnectUrl Well-known URL to discover the provider metadata. + * REQUIRED for openIdConnect type. + * + * @return SecuritySchemeObj Returns self for method chaining. + */ + public function setOpenIdConnectUrl(string $openIdConnectUrl): SecuritySchemeObj { + $this->openIdConnectUrl = $openIdConnectUrl; + return $this; + } + + /** + * Returns the OpenID Connect discovery URL. + * + * @return string|null Returns the value, or null if not set. + */ + public function getOpenIdConnectUrl(): ?string { + return $this->openIdConnectUrl; + } + + /** + * Returns a Json object that represents the Security Scheme Object. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json([ + 'type' => $this->getType() + ]); + + if ($this->getDescription() !== null) { + $json->add('description', $this->getDescription()); + } + + if ($this->getName() !== null) { + $json->add('name', $this->getName()); + } + + if ($this->getIn() !== null) { + $json->add('in', $this->getIn()); + } + + if ($this->getScheme() !== null) { + $json->add('scheme', $this->getScheme()); + } + + if ($this->getBearerFormat() !== null) { + $json->add('bearerFormat', $this->getBearerFormat()); + } + + if ($this->getFlows() !== null) { + $json->add('flows', $this->getFlows()); + } + + if ($this->getOpenIdConnectUrl() !== null) { + $json->add('openIdConnectUrl', $this->getOpenIdConnectUrl()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/ServerObj.php b/WebFiori/Http/OpenAPI/ServerObj.php new file mode 100644 index 0000000..557702a --- /dev/null +++ b/WebFiori/Http/OpenAPI/ServerObj.php @@ -0,0 +1,113 @@ +setUrl($url); + + if ($description !== null) { + $this->setDescription($description); + } + } + + /** + * Sets the URL to the target host. + * + * @param string $url A URL to the target host. This URL supports Server Variables and MAY be relative. + * + * @return ServerObj Returns self for method chaining. + */ + public function setUrl(string $url): ServerObj { + $this->url = $url; + return $this; + } + + /** + * Returns the URL to the target host. + * + * @return string + */ + public function getUrl(): string { + return $this->url; + } + + /** + * Sets the description of the host designated by the URL. + * + * @param string $description An optional string describing the host. + * CommonMark syntax MAY be used for rich text representation. + * + * @return ServerObj Returns self for method chaining. + */ + public function setDescription(string $description): ServerObj { + $this->description = $description; + return $this; + } + + /** + * Returns the description of the host. + * + * @return string|null Returns the value, or null if not set. + */ + public function getDescription(): ?string { + return $this->description; + } + + /** + * Returns a Json object that represents the Server Object. + * + * @return Json A Json object representation following OpenAPI 3.1.0 specification. + */ + public function toJSON(): Json { + $json = new Json([ + 'url' => $this->getUrl() + ]); + + if ($this->getDescription() !== null) { + $json->add('description', $this->getDescription()); + } + + return $json; + } +} diff --git a/WebFiori/Http/OpenAPI/TagObj.php b/WebFiori/Http/OpenAPI/TagObj.php new file mode 100644 index 0000000..013fc29 --- /dev/null +++ b/WebFiori/Http/OpenAPI/TagObj.php @@ -0,0 +1,146 @@ +setName($name); + + if ($description !== null) { + $this->setDescription($description); + } + } + + /** + * Sets the name of the tag. + * + * The tag name is used to group operations in the OpenAPI Description. + * + * @param string $name The name of the tag. + * + * @return TagObj Returns self for method chaining. + */ + public function setName(string $name): TagObj { + $this->name = $name; + return $this; + } + + /** + * Returns the name of the tag. + * + * @return string The tag name. + */ + public function getName(): string { + return $this->name; + } + + /** + * Sets the description for the tag. + * + * CommonMark syntax MAY be used for rich text representation. + * + * @param string $description A description for the tag. + * + * @return TagObj Returns self for method chaining. + */ + public function setDescription(string $description): TagObj { + $this->description = $description; + return $this; + } + + /** + * Returns the description for the tag. + * + * @return string|null The description, or null if not set. + */ + public function getDescription(): ?string { + return $this->description; + } + + /** + * Sets additional external documentation for this tag. + * + * @param ExternalDocObj $externalDocs External documentation object. + * + * @return TagObj Returns self for method chaining. + */ + public function setExternalDocs(ExternalDocObj $externalDocs): TagObj { + $this->externalDocs = $externalDocs; + return $this; + } + + /** + * Returns the external documentation for this tag. + * + * @return ExternalDocObj|null The external documentation object, or null if not set. + */ + public function getExternalDocs(): ?ExternalDocObj { + return $this->externalDocs; + } + + /** + * Returns a Json object that represents the Tag Object. + * + * The JSON structure follows the OpenAPI 3.1.0 specification. + * + * @return Json A Json object representation of this Tag Object. + */ + public function toJSON(): Json { + $json = new Json([ + 'name' => $this->getName() + ]); + + if ($this->getDescription() !== null) { + $json->add('description', $this->getDescription()); + } + + if ($this->getExternalDocs() !== null) { + $json->add('externalDocs', $this->getExternalDocs()); + } + + return $json; + } +} diff --git a/WebFiori/Http/ParamOption.php b/WebFiori/Http/ParamOption.php index fa53a1f..355918c 100644 --- a/WebFiori/Http/ParamOption.php +++ b/WebFiori/Http/ParamOption.php @@ -20,6 +20,10 @@ class ParamOption { * not provided. */ const DEFAULT = 'default'; + /** + * An option which is used to set the methods at which the parameter must exist. + */ + const METHODS = 'methods'; /** * An option which is used to set a description for the parameter */ @@ -36,7 +40,7 @@ class ParamOption { * An option which is used to set maximum allowed value. Applicable to numerical * types only. */ - const MAX = 'maxt'; + const MAX = 'max'; /** * An option which is used to set minimum allowed length. Applicable to string types only. */ diff --git a/WebFiori/Http/RequestParameter.php b/WebFiori/Http/RequestParameter.php index 50837df..0fb35c5 100644 --- a/WebFiori/Http/RequestParameter.php +++ b/WebFiori/Http/RequestParameter.php @@ -9,6 +9,7 @@ */ namespace WebFiori\Http; +use WebFiori\Http\OpenAPI\Schema; use WebFiori\Json\Json; use WebFiori\Json\JsonI; /** @@ -107,6 +108,13 @@ class RequestParameter implements JsonI { * */ private $type; + /** + * An array of request methods at which the parameter must exist. + * + * @var array + * + */ + private $methods; /** * Creates new instance of the class. * @@ -149,6 +157,7 @@ public function __construct(string $name, string $type = 'string', bool $isOptio } $this->applyBasicFilter = true; $this->isEmptyStrAllowed = false; + $this->methods = []; } /** * Returns a string that represents the object. @@ -225,10 +234,10 @@ public function __toString() { * If it was not created for any reason, the method will return null. * */ - public static function create(array $options) { - if (isset($options['name'])) { - $paramType = $options['type'] ?? 'string'; - $param = new RequestParameter($options['name'], $paramType); + public static function create(array $options) : ?RequestParameter { + if (isset($options[ParamOption::NAME])) { + $paramType = $options[ParamOption::TYPE] ?? 'string'; + $param = new RequestParameter($options[ParamOption::NAME], $paramType); self::checkParamAttrs($param, $options); return $param; @@ -627,7 +636,7 @@ public function setMinValue(float $val) : bool { public function setName(string $name) : bool { $nameTrimmed = trim($name); - if (AbstractWebService::isValidName($nameTrimmed)) { + if (WebService::isValidName($nameTrimmed)) { $this->name = $nameTrimmed; return true; @@ -676,20 +685,6 @@ public function setType(string $type) : bool { /** * Returns a Json object that represents the request parameter. * - * This method is used to help front-end developers in showing the - * documentation of the request parameter. The format of JSON string - * will be as follows: - *

    - * {
    - *   "name":"a-param",
    - *   "type":"string",
    - *   "description":null,
    - *   "is-optional":true,
    - *   "default-value":null,
    - *   "min-val":null,
    - *   "max-val":null
    - * } - *

    * * @return Json An object of type Json. * @@ -697,60 +692,114 @@ public function setType(string $type) : bool { public function toJSON() : Json { $json = new Json(); $json->add('name', $this->getName()); - $json->add('type', $this->getType()); - $json->add('description', $this->getDescription()); - $json->add('is-optional', $this->isOptional()); - $json->add('default-value', $this->getDefault()); - $json->add('min-val', $this->getMinValue()); - $json->add('max-val', $this->getMaxValue()); - $json->add('min-length', $this->getMinLength()); - $json->add('max-length', $this->getMaxLength()); - + + $methods = $this->getMethods(); + // Default to 'query' for GET/DELETE, 'body' for others + if (count($methods) === 0 || in_array(RequestMethod::GET, $methods) || in_array(RequestMethod::DELETE, $methods)) { + $json->add('in', 'query'); + } else { + $json->add('in', 'body'); + } + + $json->add('required', !$this->isOptional()); + + if ($this->getDescription() !== null) { + $json->add('description', $this->getDescription()); + } + + $json->add('schema', $this->getSchema()); + return $json; } + private function getSchema() : Json { + return Schema::fromRequestParameter($this)->toJson(); + } + /** * * @param RequestParameter $param * @param array $options */ private static function checkParamAttrs(RequestParameter $param, array $options) { - $isOptional = $options['optional'] ?? false; + $isOptional = $options[ParamOption::OPTIONAL] ?? false; $param->setIsOptional($isOptional); - if (isset($options['custom-filter'])) { - $param->setCustomFilterFunction($options['custom-filter']); + if (isset($options[ParamOption::FILTER])) { + $param->setCustomFilterFunction($options[ParamOption::FILTER]); } - if (isset($options['min'])) { - $param->setMinValue($options['min']); + if (isset($options[ParamOption::MIN])) { + $param->setMinValue($options[ParamOption::MIN]); } - if (isset($options['max'])) { - $param->setMaxValue($options['max']); + if (isset($options[ParamOption::MAX])) { + $param->setMaxValue($options[ParamOption::MAX]); } - if (isset($options['min-length'])) { - $param->setMinLength($options['min-length']); + if (isset($options[ParamOption::MIN_LENGTH])) { + $param->setMinLength($options[ParamOption::MIN_LENGTH]); } - if (isset($options['max-length'])) { - $param->setMaxLength($options['max-length']); + if (isset($options[ParamOption::MAX_LENGTH])) { + $param->setMaxLength($options[ParamOption::MAX_LENGTH]); } - if (isset($options['allow-empty'])) { - $param->setIsEmptyStringAllowed($options['allow-empty']); + if (isset($options[ParamOption::EMPTY])) { + $param->setIsEmptyStringAllowed($options[ParamOption::EMPTY]); } - if (isset($options['custom-filter'])) { - $param->setCustomFilterFunction($options['custom-filter']); + if (isset($options[ParamOption::METHODS])) { + $type = gettype($options[ParamOption::METHODS]); + if ($type == 'string') { + $param->addMethod($options[ParamOption::METHODS]); + } else if ($type == 'array') { + $param->addMethods($options[ParamOption::METHODS]); + } } - if (isset($options['default'])) { - $param->setDefault($options['default']); + if (isset($options[ParamOption::DEFAULT])) { + $param->setDefault($options[ParamOption::DEFAULT]); } - if (isset($options['description'])) { - $param->setDescription($options['description']); + if (isset($options[ParamOption::DESCRIPTION])) { + $param->setDescription($options[ParamOption::DESCRIPTION]); + } + } + /** + * Returns an array of request methods at which the parameter must exist. + * + * @return array An array of request method names (e.g., ['GET', 'POST']). + */ + public function getMethods(): array { + return $this->methods; + } + + /** + * Adds a request method to the parameter. + * + * @param string $requestMethod The request method name (e.g., 'GET', 'POST'). + * + * @return RequestParameter Returns self for method chaining. + */ + public function addMethod(string $requestMethod): RequestParameter { + $method = strtoupper(trim($requestMethod)); + if (!in_array($method, $this->methods) && in_array($method, RequestMethod::getAll())) { + $this->methods[] = $method; + } + return $this; + } + + /** + * Adds multiple request methods to the parameter. + * + * @param array $arr An array of request method names. + * + * @return RequestParameter Returns self for method chaining. + */ + public function addMethods(array $arr): RequestParameter { + foreach ($arr as $method) { + $this->addMethod($method); } + return $this; } } diff --git a/WebFiori/Http/SecurityContext.php b/WebFiori/Http/SecurityContext.php new file mode 100644 index 0000000..1cb8165 --- /dev/null +++ b/WebFiori/Http/SecurityContext.php @@ -0,0 +1,267 @@ +getRoles(); + self::$authorities = $user->getAuthorities(); + } else { + self::$roles = []; + self::$authorities = []; + } + } + + /** + * Get the current authenticated user. + * + * @return UserInterface|null User object or null if not authenticated + */ + public static function getCurrentUser(): ?UserInterface { + return self::$currentUser; + } + + /** + * Set user roles. + * + * @param array $roles Array of role names + * Example: ['USER', 'ADMIN', 'MODERATOR'] + */ + public static function setRoles(array $roles): void { + self::$roles = $roles; + } + + /** + * Get user roles. + * + * @return array Array of role names + */ + public static function getRoles(): array { + return self::$roles; + } + + /** + * Set user authorities/permissions. + * + * @param array $authorities Array of authority names + * Example: ['USER_CREATE', 'USER_UPDATE', 'USER_DELETE', 'REPORT_VIEW'] + */ + public static function setAuthorities(array $authorities): void { + self::$authorities = $authorities; + } + + /** + * Get user authorities/permissions. + * + * @return array Array of authority names + */ + public static function getAuthorities(): array { + return self::$authorities; + } + + /** + * Check if user has a specific role. + * + * @param string $role Role name to check + * Example: 'ADMIN', 'USER', 'MODERATOR' + * @return bool True if user has the role + */ + public static function hasRole(string $role): bool { + return in_array($role, self::$roles); + } + + /** + * Check if user has a specific authority/permission. + * + * @param string $authority Authority name to check + * Example: 'USER_CREATE', 'USER_DELETE', 'REPORT_VIEW' + * @return bool True if user has the authority + */ + public static function hasAuthority(string $authority): bool { + return in_array($authority, self::$authorities); + } + + /** + * Check if a user is currently authenticated. + * + * @return bool True if user is authenticated and active + */ + public static function isAuthenticated(): bool { + return self::$currentUser !== null && self::$currentUser->isActive(); + } + + /** + * Clear all security context data. + */ + public static function clear(): void { + self::$currentUser = null; + self::$roles = []; + self::$authorities = []; + } + + /** + * Evaluate security expression. + * + * @param string $expression Security expression to evaluate + * + * Simple expressions: + * - "hasRole('ADMIN')" - Check single role + * - "hasAuthority('USER_CREATE')" - Check single authority + * - "isAuthenticated()" - Check if user is logged in + * - "permitAll()" - Always allow access + * + * Multiple values: + * - "hasAnyRole('ADMIN', 'MODERATOR')" - Check any of multiple roles + * - "hasAnyAuthority('USER_CREATE', 'USER_UPDATE')" - Check any of multiple authorities + * + * Complex boolean expressions: + * - "hasRole('ADMIN') && hasAuthority('USER_CREATE')" - Both conditions must be true + * - "hasRole('ADMIN') || hasRole('MODERATOR')" - Either condition can be true + * - "isAuthenticated() && hasAnyRole('USER', 'ADMIN')" - Authenticated with any role + * + * @return bool True if expression evaluates to true + * @throws \InvalidArgumentException If expression is invalid + */ + public static function evaluateExpression(string $expression): bool { + $expression = trim($expression); + + if (empty($expression)) { + throw new \InvalidArgumentException('Security expression cannot be empty'); + } + + // Handle complex boolean expressions with && and || + if (strpos($expression, '&&') !== false) { + return self::evaluateAndExpression($expression); + } + + if (strpos($expression, '||') !== false) { + return self::evaluateOrExpression($expression); + } + + // Handle single expressions + return self::evaluateSingleExpression($expression); + } + + /** + * Evaluate AND expression (all conditions must be true). + */ + private static function evaluateAndExpression(string $expression): bool { + $parts = array_map('trim', explode('&&', $expression)); + + foreach ($parts as $part) { + if (!self::evaluateSingleExpression($part)) { + return false; + } + } + + return true; + } + + /** + * Evaluate OR expression (at least one condition must be true). + */ + private static function evaluateOrExpression(string $expression): bool { + $parts = array_map('trim', explode('||', $expression)); + + foreach ($parts as $part) { + if (self::evaluateSingleExpression($part)) { + return true; + } + } + + return false; + } + + /** + * Evaluate single security expression. + */ + private static function evaluateSingleExpression(string $expression): bool { + // Handle hasRole('ROLE_NAME') + if (preg_match("/hasRole\('([^']+)'\)/", $expression, $matches)) { + return self::hasRole($matches[1]); + } + + // Handle hasAnyRole('ROLE1', 'ROLE2', ...) + if (preg_match("/hasAnyRole\(([^)]+)\)/", $expression, $matches)) { + $roles = self::parseArgumentList($matches[1]); + foreach ($roles as $role) { + if (self::hasRole($role)) { + return true; + } + } + return false; + } + + // Handle hasAuthority('AUTHORITY_NAME') + if (preg_match("/hasAuthority\('([^']+)'\)/", $expression, $matches)) { + return self::hasAuthority($matches[1]); + } + + // Handle hasAnyAuthority('AUTH1', 'AUTH2', ...) + if (preg_match("/hasAnyAuthority\(([^)]+)\)/", $expression, $matches)) { + $authorities = self::parseArgumentList($matches[1]); + foreach ($authorities as $authority) { + if (self::hasAuthority($authority)) { + return true; + } + } + return false; + } + + // Handle isAuthenticated() + if ($expression === 'isAuthenticated()') { + return self::isAuthenticated(); + } + + // Handle permitAll() + if ($expression === 'permitAll()') { + return true; + } + + throw new \InvalidArgumentException("Invalid security expression: '$expression'"); + } + + /** + * Parse comma-separated argument list from function call. + */ + private static function parseArgumentList(string $args): array { + $result = []; + $parts = explode(',', $args); + + foreach ($parts as $part) { + $part = trim($part); + if (preg_match("/^'([^']+)'$/", $part, $matches)) { + $result[] = $matches[1]; + } else { + throw new \InvalidArgumentException("Invalid argument format: '$part'"); + } + } + + return $result; + } +} diff --git a/WebFiori/Http/UserInterface.php b/WebFiori/Http/UserInterface.php new file mode 100644 index 0000000..982468c --- /dev/null +++ b/WebFiori/Http/UserInterface.php @@ -0,0 +1,37 @@ + + *
  • It can contain the letters [A-Z] and [a-z].
  • + *
  • It can contain the numbers [0-9].
  • + *
  • It can have the character '-' and the character '_'.
  • + * + * If The given name is invalid, the name of the service will be set to 'new-service'. + * + * @param string $name The name of the web service. + * + * @param WebServicesManager|null $owner The manager which is used to + * manage the web service. + */ + public function __construct(string $name = '') { + $this->reqMethods = []; + $this->parameters = []; + $this->responses = []; + $this->responsesByMethod = []; + $this->requireAuth = true; + $this->sinceVersion = '1.0.0'; + $this->serviceDesc = ''; + $this->request = Request::createFromGlobals(); + + $this->configureFromAnnotations($name); + } + + /** + * Configure service from annotations if present. + */ + private function configureFromAnnotations(string $fallbackName): void { + $reflection = new \ReflectionClass($this); + $attributes = $reflection->getAttributes(\WebFiori\Http\Annotations\RestController::class); + + if (!empty($attributes)) { + $restController = $attributes[0]->newInstance(); + $serviceName = $restController->name ?: $fallbackName; + $description = $restController->description; + } else { + $serviceName = $fallbackName; + $description = ''; + } + + if (!$this->setName($serviceName)) { + $this->setName('new-service'); + } + + if ($description) { + $this->setDescription($description); + } + + $this->configureMethodMappings(); + $this->configureAuthentication(); + } + + /** + * Process the web service request with auto-processing support. + * This method should be called instead of processRequest() for auto-processing. + */ + public function processWithAutoHandling(): void { + $targetMethod = $this->getTargetMethod(); + + if ($targetMethod && $this->hasResponseBodyAnnotation($targetMethod)) { + // Check method-level authorization first + if (!$this->checkMethodAuthorization()) { + $this->sendResponse('Access denied', 403, 'error'); + return; + } + + try { + // Call the target method and process its return value + $result = $this->$targetMethod(); + $this->handleMethodResponse($result, $targetMethod); + } catch (HttpException $e) { + // Handle HTTP exceptions automatically + $this->handleException($e); + } catch (\Exception $e) { + // Handle other exceptions as 500 Internal Server Error + $this->sendResponse($e->getMessage(), 500, 'error'); + } + } else { + // Fall back to traditional processRequest() approach + $this->processRequest(); + } + } + + /** + * Check if a method has the ResponseBody annotation. + * + * @param string $methodName The method name to check + * @return bool True if the method has ResponseBody annotation + */ + public function hasResponseBodyAnnotation(string $methodName): bool { + try { + $reflection = new \ReflectionMethod($this, $methodName); + return !empty($reflection->getAttributes(ResponseBody::class)); + } catch (\ReflectionException $e) { + return false; + } + } + + /** + * Handle HTTP exceptions by converting them to appropriate responses. + * + * @param HttpException $exception The HTTP exception to handle + */ + protected function handleException(HttpException $exception): void { + $this->sendResponse( + $exception->getMessage(), + $exception->getStatusCode(), + $exception->getResponseType() + ); + } + + /** + * Configure parameters dynamically for a specific method. + * + * @param string $methodName The method name to configure parameters for + */ + public function configureParametersForMethod(string $methodName): void { + try { + $reflection = new \ReflectionMethod($this, $methodName); + $this->configureParametersFromMethod($reflection); + } catch (\ReflectionException $e) { + // Method doesn't exist, ignore + } + } + + /** + * Configure parameters for all methods with RequestParam annotations. + */ + private function configureAllAnnotatedParameters(): void { + $reflection = new \ReflectionClass($this); + foreach ($reflection->getMethods() as $method) { + $paramAttributes = $method->getAttributes(\WebFiori\Http\Annotations\RequestParam::class); + if (!empty($paramAttributes)) { + $this->configureParametersFromMethod($method); + } + } + } + + /** + * Configure parameters for methods with specific HTTP method mapping. + * + * @param string $httpMethod HTTP method (GET, POST, PUT, DELETE, etc.) + */ + private function configureParametersForHttpMethod(string $httpMethod): void { + $reflection = new \ReflectionClass($this); + $httpMethod = strtoupper($httpMethod); + + foreach ($reflection->getMethods() as $method) { + // Check if method has HTTP method mapping annotation + $mappingFound = false; + + // Check for specific HTTP method annotations + $annotations = [ + 'GET' => \WebFiori\Http\Annotations\GetMapping::class, + 'POST' => \WebFiori\Http\Annotations\PostMapping::class, + 'PUT' => \WebFiori\Http\Annotations\PutMapping::class, + 'DELETE' => \WebFiori\Http\Annotations\DeleteMapping::class, + 'PATCH' => \WebFiori\Http\Annotations\PatchMapping::class, + ]; + + if (isset($annotations[$httpMethod])) { + $mappingFound = !empty($method->getAttributes($annotations[$httpMethod])); + } + + if ($mappingFound) { + $this->configureParametersFromMethod($method); + } + } + } + + /** + * Configure authentication from annotations. + */ + private function configureAuthentication(): void { + $reflection = new \ReflectionClass($this); + + // Check class-level authentication + $classAuth = $this->getAuthenticationFromClass($reflection); + + // If class has AllowAnonymous, disable auth requirement + if ($classAuth['allowAnonymous']) { + $this->setIsAuthRequired(false); + } else if ($classAuth['requiresAuth'] || $classAuth['preAuthorize']) { + $this->setIsAuthRequired(true); + } + } + + /** + * Get authentication configuration from class annotations. + */ + private function getAuthenticationFromClass(\ReflectionClass $reflection): array { + return [ + 'allowAnonymous' => !empty($reflection->getAttributes(\WebFiori\Http\Annotations\AllowAnonymous::class)), + 'requiresAuth' => !empty($reflection->getAttributes(\WebFiori\Http\Annotations\RequiresAuth::class)), + 'preAuthorize' => $reflection->getAttributes(\WebFiori\Http\Annotations\PreAuthorize::class) + ]; + } + + /** + * Check method-level authorization before processing. + */ + public function checkMethodAuthorization(): bool { + $reflection = new \ReflectionClass($this); + $method = $this->getCurrentProcessingMethod() ?: $this->getTargetMethod(); + + if (!$method) { + return $this->isAuthorized(); + } + + $reflectionMethod = $reflection->getMethod($method); + + // Check AllowAnonymous first + if (!empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\AllowAnonymous::class))) { + return true; + } + + // Check RequiresAuth + if (!empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\RequiresAuth::class))) { + if (!SecurityContext::isAuthenticated()) { + return false; + } + } + + // Check PreAuthorize + $preAuthAttributes = $reflectionMethod->getAttributes(\WebFiori\Http\Annotations\PreAuthorize::class); + if (!empty($preAuthAttributes)) { + $preAuth = $preAuthAttributes[0]->newInstance(); + + return SecurityContext::evaluateExpression($preAuth->expression); + } + + return $this->isAuthorized(); + } + + /** + * Check if the method has any authorization annotations. + */ + public function hasMethodAuthorizationAnnotations(): bool { + $reflection = new \ReflectionClass($this); + $method = $this->getCurrentProcessingMethod() ?: $this->getTargetMethod(); + + if (!$method) { + return false; + } + + $reflectionMethod = $reflection->getMethod($method); + + return !empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\AllowAnonymous::class)) || + !empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\RequiresAuth::class)) || + !empty($reflectionMethod->getAttributes(\WebFiori\Http\Annotations\PreAuthorize::class)); + } + + /** + * Get the current processing method name (to be overridden by subclasses if needed). + */ + protected function getCurrentProcessingMethod(): ?string { + return null; // Default implementation + } + + /** + * Get the target method name based on current HTTP request. + * + * @return string|null The method name that should handle this request, or null if none found + */ + public function getTargetMethod(): ?string { + $httpMethod = $this->getManager() ? + $this->getManager()->getRequest()->getMethod() : + ($_SERVER['REQUEST_METHOD'] ?? 'GET'); + + // First try to get method from getCurrentProcessingMethod (if implemented) + $currentMethod = $this->getCurrentProcessingMethod(); + if ($currentMethod) { + $reflection = new \ReflectionClass($this); + try { + $method = $reflection->getMethod($currentMethod); + if ($this->methodHandlesHttpMethod($method, $httpMethod)) { + return $currentMethod; + } + } catch (\ReflectionException $e) { + // Method doesn't exist, continue with discovery + } + } + + // Fall back to finding first method that matches HTTP method + $reflection = new \ReflectionClass($this); + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($this->methodHandlesHttpMethod($method, $httpMethod)) { + return $method->getName(); + } + } + + return null; + } + + /** + * Check if a method handles the specified HTTP method. + * + * @param \ReflectionMethod $method The method to check + * @param string $httpMethod The HTTP method (GET, POST, etc.) + * @return bool True if the method handles this HTTP method + */ + private function methodHandlesHttpMethod(\ReflectionMethod $method, string $httpMethod): bool { + $methodMappings = [ + GetMapping::class => RequestMethod::GET, + PostMapping::class => RequestMethod::POST, + PutMapping::class => RequestMethod::PUT, + DeleteMapping::class => RequestMethod::DELETE + ]; + + foreach ($methodMappings as $annotationClass => $mappedMethod) { + if ($httpMethod === $mappedMethod && !empty($method->getAttributes($annotationClass))) { + return true; + } + } + + return false; + } + + /** + * Handle method response by auto-converting return values to HTTP responses. + * + * @param mixed $result The return value from the method + * @param string $methodName The name of the method that was called + * @return void + */ + protected function handleMethodResponse(mixed $result, string $methodName): void { + $reflection = new \ReflectionMethod($this, $methodName); + $responseBodyAttrs = $reflection->getAttributes(ResponseBody::class); + + if (empty($responseBodyAttrs)) { + return; // No auto-processing, method should handle response manually + } + + $responseBody = $responseBodyAttrs[0]->newInstance(); + + // Auto-convert return value to response + if ($result === null) { + // Null return = empty response with configured status + $this->sendResponse('', $responseBody->status, $responseBody->type); + } elseif (is_array($result) || is_object($result)) { + // Array/object = JSON response + $this->sendResponse('Success', $responseBody->status, $responseBody->type, $result); + } else { + // String/scalar = plain response + $this->sendResponse($result, $responseBody->status, $responseBody->type); + } + } + + /** + * Configure allowed HTTP methods from method annotations. + */ + private function configureMethodMappings(): void { + $reflection = new \ReflectionClass($this); + $httpMethodToMethods = []; + + foreach ($reflection->getMethods() as $method) { + $methodMappings = [ + GetMapping::class => RequestMethod::GET, + PostMapping::class => RequestMethod::POST, + PutMapping::class => RequestMethod::PUT, + DeleteMapping::class => RequestMethod::DELETE + ]; + + foreach ($methodMappings as $annotationClass => $httpMethod) { + $attributes = $method->getAttributes($annotationClass); + if (!empty($attributes)) { + if (!isset($httpMethodToMethods[$httpMethod])) { + $httpMethodToMethods[$httpMethod] = []; + } + $httpMethodToMethods[$httpMethod][] = $method->getName(); + } + } + } + + // Check for duplicates only if getCurrentProcessingMethod is not overridden + $hasCustomRouting = $reflection->getMethod('getCurrentProcessingMethod')->getDeclaringClass()->getName() !== self::class; + + if (!$hasCustomRouting) { + foreach ($httpMethodToMethods as $httpMethod => $methods) { + if (count($methods) > 1) { + throw new Exceptions\DuplicateMappingException( + "HTTP method $httpMethod is mapped to multiple methods: " . implode(', ', $methods) + ); + } + } + } + + if (!empty($httpMethodToMethods)) { + $this->setRequestMethods(array_keys($httpMethodToMethods)); + } + } + + /** + * Configure parameters from method RequestParam annotations. + */ + private function configureParametersFromMethod(\ReflectionMethod $method): void { + $paramAttributes = $method->getAttributes(\WebFiori\Http\Annotations\RequestParam::class); + + foreach ($paramAttributes as $attribute) { + $param = $attribute->newInstance(); + + $this->addParameters([ + $param->name => [ + \WebFiori\Http\ParamOption::TYPE => $this->mapParamType($param->type), + \WebFiori\Http\ParamOption::OPTIONAL => $param->optional, + \WebFiori\Http\ParamOption::DEFAULT => $param->default, + \WebFiori\Http\ParamOption::DESCRIPTION => $param->description + ] + ]); + } + } + + /** + * Map string type to ParamType constant. + */ + private function mapParamType(string $type): string { + return match(strtolower($type)) { + 'int', 'integer' => \WebFiori\Http\ParamType::INT, + 'float', 'double' => \WebFiori\Http\ParamType::DOUBLE, + 'bool', 'boolean' => \WebFiori\Http\ParamType::BOOL, + 'email' => \WebFiori\Http\ParamType::EMAIL, + 'url' => \WebFiori\Http\ParamType::URL, + 'array' => \WebFiori\Http\ParamType::ARR, + 'json' => \WebFiori\Http\ParamType::JSON_OBJ, + default => \WebFiori\Http\ParamType::STRING + }; + } /** + * Returns an array that contains all possible requests methods at which the + * service can be called with. + * + * The array will contain strings like 'GET' or 'POST'. If no request methods + * where added, the array will be empty. + * + * @return array An array that contains all possible requests methods at which the + * service can be called using. + * + */ + public function &getRequestMethods() : array { + return $this->reqMethods; + } + /** + * Returns an array that contains an objects of type RequestParameter. + * + * @return array an array that contains an objects of type RequestParameter. + * + */ + public final function &getParameters() : array { + return $this->parameters; + } + /** + * + * @return string + * + */ + public function __toString() { + return $this->toJSON().''; + } + /** + * Adds new request parameter to the service. + * + * The parameter will only be added if no parameter which has the same + * name as the given one is added before. + * + * @param RequestParameter|array $param The parameter that will be added. It + * can be an object of type 'RequestParameter' or an associative array of + * options. The array can have the following indices: + * + * + * @return bool If the given request parameter is added, the method will + * return true. If it was not added for any reason, the method will return + * false. + * + */ + public function addParameter($param) : bool { + if (gettype($param) == 'array') { + $param = RequestParameter::create($param); + } + + if ($param instanceof RequestParameter && !$this->hasParameter($param->getName())) { + $this->parameters[] = $param; + + return true; + } + + return false; + } + /** + * Adds multiple parameters to the web service in one batch. + * + * @param array $params An associative or indexed array. If the array is indexed, + * each index should hold an object of type 'RequestParameter'. If it is associative, + * then the key will represent the name of the web service and the value of the + * key should be a sub-associative array that holds parameter options. + * + */ + public function addParameters(array $params) { + foreach ($params as $paramIndex => $param) { + if ($param instanceof RequestParameter) { + $this->addParameter($param); + } else if (gettype($param) == 'array') { + $param['name'] = $paramIndex; + $this->addParameter(RequestParameter::create($param)); + } + } + } + /** + * Adds new request method. + * + * The value that will be passed to this method can be any string + * that represents HTTP request method (e.g. 'get', 'post', 'options' ...). It + * can be in upper case or lower case. + * + * @param string $method The request method. + * + * @return bool true in case the request method is added. If the given + * request method is already added or the method is unknown, the method + * will return false. + * + */ + public final function addRequestMethod(string $method) : bool { + $uMethod = strtoupper(trim($method)); + + if (in_array($uMethod, RequestMethod::getAll()) && !in_array($uMethod, $this->reqMethods)) { + $this->reqMethods[] = $uMethod; + + return true; + } + + return false; + } + /** + * Adds response description. + * + * It is used to describe the API for front-end developers and help them + * identify possible responses if they call the API using the specified service. + * + * @param string $description A paragraph that describes one of + * the possible responses due to calling the service. + */ + public function addResponse(string $method, string $statusCode, OpenAPI\ResponseObj|string $response): WebService { + $method = strtoupper($method); + + if (!isset($this->responsesByMethod[$method])) { + $this->responsesByMethod[$method] = new OpenAPI\ResponsesObj(); + } + + $this->responsesByMethod[$method]->addResponse($statusCode, $response); + return $this; + } + + public final function addResponseDescription(string $description) { + $trimmed = trim($description); + + if (strlen($trimmed) != 0) { + $this->responses[] = $trimmed; + } + } + public function getResponsesForMethod(string $method): ?OpenAPI\ResponsesObj { + $method = strtoupper($method); + return $this->responsesByMethod[$method] ?? null; + } + /** + * Sets all responses for a specific HTTP method. + * + * @param string $method HTTP method. + * @param OpenAPI\ResponsesObj $responses Responses object. + * + * @return WebService Returns self for method chaining. + */ + public function setResponsesForMethod(string $method, OpenAPI\ResponsesObj $responses): WebService { + $this->responsesByMethod[strtoupper($method)] = $responses; + return $this; + } + + /** + * Gets all responses mapped by HTTP method. + * + * @return array Map of methods to responses. + */ + public function getAllResponses(): array { + return $this->responsesByMethod; + } + + /** + * Converts this web service to an OpenAPI PathItemObj. + * + * Each HTTP method supported by this service becomes an operation in the path item. + * + * @return OpenAPI\PathItemObj The PathItemObj representation of this service. + */ + public function toPathItemObj(): OpenAPI\PathItemObj { + $pathItem = new OpenAPI\PathItemObj(); + + foreach ($this->getRequestMethods() as $method) { + $responses = $this->getResponsesForMethod($method); + + if ($responses === null) { + $responses = new OpenAPI\ResponsesObj(); + $responses->addResponse('200', 'Successful operation'); + } + + $operation = new OpenAPI\OperationObj($responses); + + switch ($method) { + case RequestMethod::GET: + $pathItem->setGet($operation); + break; + case RequestMethod::POST: + $pathItem->setPost($operation); + break; + case RequestMethod::PUT: + $pathItem->setPut($operation); + break; + case RequestMethod::DELETE: + $pathItem->setDelete($operation); + break; + case RequestMethod::PATCH: + $pathItem->setPatch($operation); + break; + } + + + }return $pathItem;} + /** + * Returns an object that contains the value of the header 'authorization'. + * + * @return AuthHeader|null The object will have two primary attributes, the first is + * the 'scheme' and the second one is 'credentials'. The 'scheme' + * will contain the name of the scheme which is used to authenticate + * ('basic', 'bearer', 'digest', etc...). The 'credentials' will contain + * the credentials which can be used to authenticate the client. + * + */ + public function getAuthHeader() { + if ($this->request !== null) { + return $this->request->getAuthHeader(); + } + return null; + } + + /** + * Sets the request instance for the service. + * + * @param mixed $request The request instance (Request, etc.) + */ + public function setRequest($request) { + $this->request = $request; + } + /** + * Returns the description of the service. + * + * @return string The description of the service. Default is empty string. + * + */ + public final function getDescription() : string { + return $this->serviceDesc; + } + /** + * Returns an associative array or an object of type Json of filtered request inputs. + * + * The indices of the array will represent request parameters and the + * values of each index will represent the value which was set in + * request body. The values will be filtered and might not be exactly the same as + * the values passed in request body. Note that if a parameter is optional and not + * provided in request body, its value will be set to 'null'. Note that + * if request content type is 'application/json', only basic filtering will + * be applied. Also, parameters in this case don't apply. + * + * @return array|Json|null An array of filtered request inputs. This also can + * be an object of type 'Json' if request content type was 'application/json'. + * If no manager was associated with the service, the method will return null. + * + */ + public function getInputs() { + $manager = $this->getManager(); + + if ($manager !== null) { + return $manager->getInputs(); + } + + return null; + } + /** + * Returns the manager which is used to manage the web service. + * + * @return WebServicesManager|null If set, it is returned as an object. + * Other than that, null is returned. + */ + public function getManager() { + return $this->owner; + } + /** + * Returns the name of the service. + * + * @return string The name of the service. + * + */ + public final function getName() : string { + return $this->name; + } + /** + * Map service parameter to specific instance of a class. + * + * This method assumes that every parameter in the request has a method + * that can be called to set attribute value. For example, if a parameter + * has the name 'user-last-name', the mapping method should have the name + * 'setUserLastName' for mapping to work correctly. + * + * @param string $clazz The class that service parameters will be mapped + * to. + * + * @param array $settersMap An optional array that can have custom + * setters map. The indices of the array should be parameters names + * and the values are the names of setter methods in the class. + * + * @return object The Method will return an instance of the class with + * all its attributes set to request parameter's values. + */ + public function getObject(string $clazz, array $settersMap = []) { + $mapper = new ObjectMapper($clazz, $this); + + foreach ($settersMap as $param => $method) { + $mapper->addSetterMap($param, $method); + } + + return $mapper->map($this->getInputs()); + } + /** + * Returns one of the parameters of the service given its name. + * + * @param string $paramName The name of the parameter. + * + * @return RequestParameter|null Returns an objects of type RequestParameter if + * a parameter with the given name was found. null if nothing is found. + * + */ + public final function getParameterByName(string $paramName, ?string $httpMethod = null) { + // Configure parameters if HTTP method specified + if ($httpMethod !== null) { + $this->configureParametersForHttpMethod($httpMethod); + } else { + // Configure parameters for all methods with annotations + $this->configureAllAnnotatedParameters(); + } + + $trimmed = trim($paramName); + + if (strlen($trimmed) != 0) { + foreach ($this->parameters as $param) { + if ($param->getName() == $trimmed) { + return $param; + } + } + } + + return null; + } + /** + * Returns the value of request parameter given its name. + * + * @param string $paramName The name of request parameter as specified when + * it was added to the service. + * + * @return mixed|null If the parameter is found and its value is set, the + * method will return its value. Other than that, the method will return null. + * For optional parameters, if a default value is set for it, the method will + * return that value. + * + */ + public function getParamVal(string $paramName) { + $inputs = $this->getInputs(); + $trimmed = trim($paramName); + + if ($inputs !== null) { + if ($inputs instanceof Json) { + return $inputs->get($trimmed); + } else { + return $inputs[$trimmed] ?? null; + } + } + + return null; + } + /** + * Returns an indexed array that contains information about possible responses. + * + * It is used to describe the API for front-end developers and help them + * identify possible responses if they call the API using the specified service. + * + * @return array An array that contains information about possible responses. + * + */ + public final function getResponsesDescriptions() : array { + return $this->responses; + } + /** + * Returns version number or name at which the service was added to the API. + * + * Version number is set based on the version number which was set in the + * class WebAPI. + * + * @return string The version number at which the service was added to the API. + * Default is '1.0.0'. + * + */ + public final function getSince() : string { + return $this->sinceVersion; + } + /** + * Checks if the service has a specific request parameter given its name. + * + * Note that the name of the parameter is case-sensitive. This means that + * 'get-profile' is not the same as 'Get-Profile'. + * + * @param string $name The name of the parameter. + * + * @return bool If a request parameter which has the given name is added + * to the service, the method will return true. Otherwise, the method will return + * false. + * + */ + public function hasParameter(string $name) : bool { + $trimmed = trim($name); + + if (strlen($name) != 0) { + foreach ($this->getParameters() as $param) { + if ($param->getName() == $trimmed) { + return true; + } + } + } + + return false; + } + /** + * Checks if the client is authorized to use the service or not. + * + * The developer should implement this method in a way it returns a boolean. + * If the method returns true, it means the client is allowed to use the service. + * If the method returns false, then he is not authorized and a 401 error + * code will be sent back. If the method returned nothing, then it means the + * user is authorized to call the API. If WebFiori framework is used, it is + * possible to perform the functionality of this method using middleware. + * + * @return bool True if the user is allowed to perform the action. False otherwise. + * + */ + public function isAuthorized() : bool {return false;} + /** + * Returns the value of the property 'requireAuth'. + * + * The property is used to tell if the authorization step will be skipped + * or not when the service is called. + * + * @return bool The method will return true if authorization step required. + * False if the authorization step will be skipped. Default return value is true. + * + */ + public function isAuthRequired() : bool { + return $this->requireAuth; + } + + /** + * Validates the name of a web service or request parameter. + * + * @param string $name The name of the service or parameter. + * + * @return bool If valid, true is returned. Other than that, false is returned. + */ + public static function isValidName(string $name): bool { + $trimmedName = trim($name); + $len = strlen($trimmedName); + + if ($len != 0) { + for ($x = 0 ; $x < $len ; $x++) { + $ch = $trimmedName[$x]; + + if (!($ch == '_' || $ch == '-' || ($ch >= 'a' && $ch <= 'z') || ($ch >= 'A' && $ch <= 'Z') || ($ch >= '0' && $ch <= '9'))) { + return false; + } + } + + return true; + } + + return false; + } + /** + * Process client's request. + */ + public function processRequest() {} + /** + * Removes a request parameter from the service given its name. + * + * @param string $paramName The name of the parameter (case-sensitive). + * + * @return null|RequestParameter If a parameter which has the given name + * was removed, the method will return an object of type 'RequestParameter' + * that represents the removed parameter. If nothing is removed, the + * method will return null. + * + */ + public function removeParameter(string $paramName) { + $trimmed = trim($paramName); + $params = &$this->getParameters(); + $index = -1; + $count = count($params); + + for ($x = 0 ; $x < $count ; $x++) { + if ($params[$x]->getName() == $trimmed) { + $index = $x; + break; + } + } + $retVal = null; + + if ($index != -1) { + if ($count == 1) { + $retVal = $params[0]; + unset($params[0]); + } else { + $retVal = $params[$index]; + $params[$index] = $params[$count - 1]; + unset($params[$count - 1]); + } + } + + return $retVal; + } + /** + * Removes a request method from the previously added ones. + * + * @param string $method The request method (e.g. 'get', 'post', 'options' ...). It + * can be in upper case or lower case. + * + * @return bool If the given request method is remove, the method will + * return true. Other than that, the method will return true. + * + */ + public function removeRequestMethod(string $method): bool { + $uMethod = strtoupper(trim($method)); + $allowedMethods = &$this->getRequestMethods(); + + if (in_array($uMethod, $allowedMethods)) { + $count = count($allowedMethods); + $methodIndex = -1; + + for ($x = 0 ; $x < $count ; $x++) { + if ($this->getRequestMethods()[$x] == $uMethod) { + $methodIndex = $x; + break; + } + } + + if ($count == 1) { + unset($allowedMethods[0]); + } else { + $allowedMethods[$methodIndex] = $allowedMethods[$count - 1]; + unset($allowedMethods[$count - 1]); + } + + return true; + } + + return false; + } + /** + * Sends Back a data using specific content type and specific response code. + * + * @param string $contentType Response content type (such as 'application/json') + * + * @param mixed $data Any data to send back. Mostly, it will be a string. + * + * @param int $code HTTP response code that will be used to send the data. + * Default is HTTP code 200 - Ok. + * + */ + public function send(string $contentType, $data, int $code = 200) { + $manager = $this->getManager(); + + if ($manager !== null) { + $manager->send($contentType, $data, $code); + } + } + /** + * Sends a JSON response to the client. + * + * The basic format of the message will be as follows: + *

    + * {
    + *   "message":"Action is not set.",
    + *   "type":"error"
    + *   "http-code":404
    + *   "more-info":EXTRA_INFO
    + * } + *

    + * Where EXTRA_INFO can be a simple string or any JSON data. + * + * @param string $message The message to send back. + * + * @param string $type A string that tells the client what is the type of + * the message. The developer can specify his own message types such as + * 'debug', 'info' or any string. If it is empty string, it will be not + * included in response payload. + * + * @param int $code Response code (such as 404 or 200). Default is 200. + * + * @param mixed $otherInfo Any other data to send back it can be a simple + * string, an object... . If null is given, the parameter 'more-info' + * will be not included in response. Default is empty string. Default is null. + * + */ + public function sendResponse(string $message, int $code = 200, string $type = '', mixed $otherInfo = '') { + $manager = $this->getManager(); + + if ($manager !== null) { + $manager->sendResponse($message, $code, $type, $otherInfo); + } + } + /** + * Sets the description of the service. + * + * Used to help front-end to identify the use of the service. + * + * @param string $desc Action description. + * + */ + public final function setDescription(string $desc) { + $this->serviceDesc = trim($desc); + } + /** + * Sets the value of the property 'requireAuth'. + * + * The property is used to tell if the authorization step will be skipped + * or not when the service is called. + * + * @param bool $bool True to make authorization step required. False to + * skip the authorization step. + * + */ + public function setIsAuthRequired(bool $bool) { + $this->requireAuth = $bool; + } + /** + * Associate the web service with a manager. + * + * The developer does not have to use this method. It is used when a + * service is added to a manager. + * + * @param WebServicesManager|null $manager The manager at which the service + * will be associated with. If null is given, the association will be removed if + * the service was associated with a manager. + * + */ + public function setManager(?WebServicesManager $manager) { + if ($manager === null) { + $this->owner = null; + } else { + $this->owner = $manager; + } + } + /** + * Sets the name of the service. + * + * A valid service name must follow the following rules: + * + * + * @param string $name The name of the web service. + * + * @return bool If the given name is valid, the method will return + * true once the name is set. false is returned if the given + * name is invalid. + * + */ + public final function setName(string $name) : bool { + if (self::isValidName($name)) { + $this->name = trim($name); + + return true; + } + + return false; + } + /** + * Adds multiple request methods as one group. + * + * @param array $methods + */ + public function setRequestMethods(array $methods) { + foreach ($methods as $m) { + $this->addRequestMethod($m); + } + } + /** + * Sets version number or name at which the service was added to a manager. + * + * This method is called automatically when the service is added to any services manager. + * The developer does not have to use this method. + * + * @param string $sinceAPIv The version number at which the service was added to the API. + * + */ + public final function setSince(string $sinceAPIv) { + $this->sinceVersion = $sinceAPIv; + } + /** + * Returns a Json object that represents the service. + * + * @return Json an object of type Json. + * + */ + public function toJSON() : Json { + return $this->toPathItemObj()->toJSON(); + } +} diff --git a/WebFiori/Http/WebServicesManager.php b/WebFiori/Http/WebServicesManager.php index c504092..1fe6c5f 100644 --- a/WebFiori/Http/WebServicesManager.php +++ b/WebFiori/Http/WebServicesManager.php @@ -96,6 +96,12 @@ class WebServicesManager implements JsonI { * */ private $services; + /** + * The base path for all services in this manager. + * + * @var string + */ + private string $basePath = ''; private $request; /** * The response object used to send output. @@ -147,11 +153,11 @@ public function getResponse() : Response { /** * Adds new web service to the set of web services. * - * @param AbstractWebService $service The web service that will be added. + * @param WebService $service The web service that will be added. * * */ - public function addService(AbstractWebService $service) : WebServicesManager { + public function addService(WebService $service) : WebServicesManager { return $this->addAction($service); } /** @@ -175,7 +181,7 @@ public function addService(AbstractWebService $service) : WebServicesManager { public function contentTypeNotSupported(string $cType = '') { $j = new Json(); $j->add('request-content-type', $cType); - $this->sendResponse(ResponseMessage::get('415'), 415, AbstractWebService::E, $j); + $this->sendResponse(ResponseMessage::get('415'), 415, WebService::E, $j); } /** * Returns the name of the service which is being called. @@ -203,6 +209,29 @@ public function getCalledServiceName() { public function getDescription() { return $this->apiDesc; } + /** + * Sets the base path for all services in this manager. + * + * The base path will be prepended to each service name when generating paths. + * For example, if base path is "/api/v1" and service name is "user", + * the final path will be "/api/v1/user". + * + * @param string $basePath The base path (e.g., "/api/v1"). Leading/trailing slashes are handled automatically. + * + * @return WebServicesManager Returns self for method chaining. + */ + public function setBasePath(string $basePath): WebServicesManager { + $this->basePath = rtrim($basePath, '/'); + return $this; + } + /** + * Returns the base path for all services. + * + * @return string The base path. + */ + public function getBasePath(): string { + return $this->basePath; + } /** * Returns an associative array or an object of type Json of filtered request inputs. * @@ -281,7 +310,7 @@ public function getOutputStreamPath() { * * @param string $serviceName The name of the service. * - * @return AbstractWebService|null The method will return an object of type 'WebService' + * @return WebService|null The method will return an object of type 'WebService' * if the service is found. If no service was found which has the given name, * The method will return null. * @@ -342,7 +371,7 @@ public function invParams() { } $i++; } - $this->sendResponse(ResponseMessage::get('404-1').$val.'.', 404, AbstractWebService::E, new Json([ + $this->sendResponse(ResponseMessage::get('404-1').$val.'.', 404, WebService::E, new Json([ 'invalid' => $paramsNamesArr ])); } @@ -398,7 +427,7 @@ public function missingParams() { } $i++; } - $this->sendResponse(ResponseMessage::get('404-2').$val.'.', 404, AbstractWebService::E, new Json([ + $this->sendResponse(ResponseMessage::get('404-2').$val.'.', 404, WebService::E, new Json([ 'missing' => $paramsNamesArr ])); } @@ -417,7 +446,7 @@ public function missingParams() { * */ public function missingServiceName() { - $this->sendResponse(ResponseMessage::get('404-3'), 404, AbstractWebService::E); + $this->sendResponse(ResponseMessage::get('404-3'), 404, WebService::E); } /** * Sends a response message to indicate that a user is not authorized call a @@ -434,7 +463,7 @@ public function missingServiceName() { * */ public function notAuth() { - $this->sendResponse(ResponseMessage::get('401'), 401, AbstractWebService::E); + $this->sendResponse(ResponseMessage::get('401'), 401, WebService::E); } /** @@ -452,20 +481,32 @@ public final function process() { if ($this->isContentTypeSupported()) { if ($this->_checkAction()) { $actionObj = $this->getServiceByName($this->getCalledServiceName()); + + // Configure parameters for ResponseBody services before getting them + if ($this->serviceHasResponseBodyMethods($actionObj)) { + $this->configureServiceParameters($actionObj); + } + + $params = $actionObj->getParameters(); $params = $actionObj->getParameters(); $this->filter->clearParametersDef(); $this->filter->clearInputs(); - + $requestMethod = $this->getRequest()->getRequestMethod(); + foreach ($params as $param) { - $this->filter->addRequestParameter($param); + $paramMethods = $param->getMethods(); + + if (count($paramMethods) == 0 || in_array($requestMethod, $paramMethods)) { + $this->filter->addRequestParameter($param); + } } $this->filterInputsHelper(); $i = $this->getInputs(); if (!($i instanceof Json)) { - $this->_processNonJson($params); + $this->_processNonJson($this->filter->getParameters()); } else { - $this->_processJson($params); + $this->_processJson($this->filter->getParameters()); } } } else { @@ -501,7 +542,7 @@ public function readOutputStream() { * * @param string $name The name of the service. * - * @return AbstractWebService|null If a web service which has the given name was found + * @return WebService|null If a web service which has the given name was found * and removed, the method will return an object that represent the removed * service. Other than that, the method will return null. * @@ -542,7 +583,7 @@ public function removeServices() { * */ public function requestMethodNotAllowed() { - $this->sendResponse(ResponseMessage::get('405'), 405, AbstractWebService::E); + $this->sendResponse(ResponseMessage::get('405'), 405, WebService::E); } /** * Sends Back a data using specific content type and specific response code. @@ -646,7 +687,7 @@ public function sendResponse(string $message, int $code = 200, string $type = '' * */ public function serviceNotImplemented() { - $this->sendResponse(ResponseMessage::get('404-4'), 404, AbstractWebService::E); + $this->sendResponse(ResponseMessage::get('404-4'), 404, WebService::E); } /** * Sends a response message to indicate that called web service is not supported by the API. @@ -662,7 +703,7 @@ public function serviceNotImplemented() { * */ public function serviceNotSupported() { - $this->sendResponse(ResponseMessage::get('404-5'), 404, AbstractWebService::E); + $this->sendResponse(ResponseMessage::get('404-5'), 404, WebService::E); } /** * Sets the description of the web services set. @@ -759,6 +800,32 @@ public final function setVersion(string $val) : bool { return false; } + /** + * Converts the services manager to an OpenAPI document. + * + * This method generates a complete OpenAPI 3.1.0 specification document + * from the registered services. Each service becomes a path in the document. + * + * @return OpenAPI\OpenAPIObj The OpenAPI document. + */ + public function toOpenAPI(): OpenAPI\OpenAPIObj { + $info = new OpenAPI\InfoObj( + $this->getDescription(), + $this->getVersion() + ); + + $openapi = new OpenAPI\OpenAPIObj($info); + + $paths = new OpenAPI\PathsObj(); + foreach ($this->getServices() as $service) { + $path = $this->basePath . '/' . $service->getName(); + $paths->addPath($path, $service->toPathItemObj()); + } + + $openapi->setPaths($paths); + + return $openapi; + } /** * Returns Json object that represents services set. * @@ -843,7 +910,7 @@ private function _checkAction(): bool { return true; } } else { - $this->sendResponse(ResponseMessage::get('404-6'), 404, AbstractWebService::E); + $this->sendResponse(ResponseMessage::get('404-6'), 404, WebService::E); } } else { $this->serviceNotSupported(); @@ -892,12 +959,12 @@ private function _processNonJson($params) { /** * Adds new web service to the set of web services. * - * @param AbstractWebService $service The web service that will be added. + * @param WebService $service The web service that will be added. * * * @deprecated since version 1.4.7 Use WebservicesSet::addService() */ - private function addAction(AbstractWebService $service) : WebServicesManager { + private function addAction(WebService $service) : WebServicesManager { $this->services[$service->getName()] = $service; $service->setManager($this); return $this; @@ -976,8 +1043,17 @@ private function getAction() { return $retVal; } - private function isAuth(AbstractWebService $service) { + private function isAuth(WebService $service) { + $isAuth = false; + if ($service->isAuthRequired()) { + // Check if method has authorization annotations + if ($service->hasMethodAuthorizationAnnotations()) { + // Use annotation-based authorization + return $service->checkMethodAuthorization(); + } + + // Fall back to legacy HTTP-method-specific authorization $isAuthCheck = 'isAuthorized'.$this->getRequest()->getMethod(); if (!method_exists($service, $isAuthCheck)) { @@ -989,7 +1065,15 @@ private function isAuth(AbstractWebService $service) { return true; } - private function processService(AbstractWebService $service) { + private function processService(WebService $service) { + // Try auto-processing only if service has ResponseBody methods + if ($this->serviceHasResponseBodyMethods($service)) { + // Configure parameters for the target method before processing + $this->configureServiceParameters($service); + $service->processWithAutoHandling(); + return; + } + $processMethod = 'process'.$this->getRequest()->getMethod(); if (!method_exists($service, $processMethod)) { @@ -998,7 +1082,35 @@ private function processService(AbstractWebService $service) { $service->$processMethod(); } } - + /** + * Check if service has any methods with ResponseBody annotation. + */ + private function serviceHasResponseBodyMethods(WebService $service): bool { + $reflection = new \ReflectionClass($service); + + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + $attributes = $method->getAttributes(\WebFiori\Http\Annotations\ResponseBody::class); + if (!empty($attributes)) { + return true; + } + } + + return false; + } + + /** + * Configure parameters for the target method of a service. + */ + private function configureServiceParameters(WebService $service): void { + if (method_exists($service, 'getTargetMethod')) { + $targetMethod = $service->getTargetMethod(); + if ($targetMethod && method_exists($service, 'configureParametersForMethod')) { + $reflection = new \ReflectionMethod($service, 'configureParametersForMethod'); + $reflection->setAccessible(true); + $reflection->invoke($service, $targetMethod); + } + } + } private function setOutputStreamHelper($trimmed, $mode) : bool { $tempStream = fopen($trimmed, $mode); diff --git a/examples/AnnotatedService.php b/examples/AnnotatedService.php new file mode 100644 index 0000000..542d83b --- /dev/null +++ b/examples/AnnotatedService.php @@ -0,0 +1,22 @@ +setRequestMethods([RequestMethod::GET]); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $this->sendResponse('Hello from annotated service!'); + } +} + diff --git a/examples/AuthTestService.php b/examples/AuthTestService.php new file mode 100644 index 0000000..4103d18 --- /dev/null +++ b/examples/AuthTestService.php @@ -0,0 +1,19 @@ + 'You have super admin access!']; + } +} diff --git a/examples/AuthenticatedController.php b/examples/AuthenticatedController.php new file mode 100644 index 0000000..93562a9 --- /dev/null +++ b/examples/AuthenticatedController.php @@ -0,0 +1,142 @@ +sendResponse('This is public information - no authentication required'); + } + + #[GetMapping] + #[RequiresAuth] + public function getProfile() { + $user = SecurityContext::getCurrentUser(); + $this->sendResponse('User profile', 200, 'success', [ + 'user' => $user, + 'roles' => SecurityContext::getRoles(), + 'authorities' => SecurityContext::getAuthorities() + ]); + } + + #[PostMapping] + #[PreAuthorize("hasRole('ADMIN')")] + public function adminOperation() { + $this->sendResponse('Admin operation completed successfully'); + } + + #[PostMapping] + #[PreAuthorize("hasAuthority('USER_MANAGE')")] + public function manageUsers() { + $this->sendResponse('User management operation completed'); + } + + public function isAuthorized(): bool { + // This is the fallback authorization check + // In a real application, you might check JWT tokens, session, etc. + return true; + } + + public function processRequest() { + // Check method-level authorization first + if (!$this->checkMethodAuthorization()) { + $this->sendResponse('Access denied', 403, 'error'); + return; + } + + $action = $_GET['action'] ?? 'public'; + + switch ($action) { + case 'public': + $this->getPublicInfo(); + break; + case 'profile': + $this->getProfile(); + break; + case 'admin': + $this->adminOperation(); + break; + case 'manage': + $this->manageUsers(); + break; + default: + $this->sendResponse('Unknown action', 400, 'error'); + } + } + + protected function getCurrentProcessingMethod(): ?string { + $action = $_GET['action'] ?? 'public'; + return match($action) { + 'public' => 'getPublicInfo', + 'profile' => 'getProfile', + 'admin' => 'adminOperation', + 'manage' => 'manageUsers', + default => null + }; + } +} + +// Demo usage +// echo "=== Authentication Demo ===\n"; + +// $controller = new AuthenticatedController(); + +// // Test 1: Public access (no auth required) +// echo "\n1. Testing public access:\n"; +// $_GET['action'] = 'public'; +// $controller->processRequest(); + +// // Test 2: Private access without authentication +// echo "\n2. Testing private access without auth:\n"; +// $_GET['action'] = 'profile'; +// $controller->processRequest(); + +// Test 3: Set up authentication +// echo "\n3. Setting up authentication:\n"; +// SecurityContext::setCurrentUser(['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com']); +// SecurityContext::setRoles(['USER']); +// SecurityContext::setAuthorities(['USER_READ']); + +// echo "User authenticated: " . (SecurityContext::isAuthenticated() ? 'Yes' : 'No') . "\n"; +// echo "Roles: " . implode(', ', SecurityContext::getRoles()) . "\n"; +// echo "Authorities: " . implode(', ', SecurityContext::getAuthorities()) . "\n"; + +// // Test 4: Private access with authentication +// echo "\n4. Testing private access with auth:\n"; +// $_GET['action'] = 'profile'; +// $controller->processRequest(); + +// // Test 5: Admin access without admin role +// echo "\n5. Testing admin access without admin role:\n"; +// $_GET['action'] = 'admin'; +// $controller->processRequest(); + +// // Test 6: Grant admin role and try again +// echo "\n6. Granting admin role and testing admin access:\n"; +// SecurityContext::setRoles(['USER', 'ADMIN']); +// $controller->processRequest(); + +// // Test 7: Authority-based access +// echo "\n7. Testing authority-based access:\n"; +// $_GET['action'] = 'manage'; +// $controller->processRequest(); + +// // Test 8: Grant required authority +// echo "\n8. Granting USER_MANAGE authority:\n"; +// SecurityContext::setAuthorities(['USER_READ', 'USER_MANAGE']); +// $controller->processRequest(); + +// // Cleanup +// SecurityContext::clear(); +// unset($_GET['action']); diff --git a/examples/CompleteApiDemo.php b/examples/CompleteApiDemo.php new file mode 100644 index 0000000..16f4e4a --- /dev/null +++ b/examples/CompleteApiDemo.php @@ -0,0 +1,124 @@ +getParamVal('id'); + + if ($id) { + // Get specific user + if ($id <= 0) { + throw new BadRequestException('Invalid user ID'); + } + + if ($id === 404) { + throw new NotFoundException('User not found'); + } + + return [ + 'user' => [ + 'id' => $id, + 'name' => 'John Doe', + 'email' => 'john@example.com' + ] + ]; + } else { + // Get all users + return [ + 'users' => [ + ['id' => 1, 'name' => 'John Doe'], + ['id' => 2, 'name' => 'Jane Smith'] + ], + 'total' => 2 + ]; + } + } + + #[PostMapping] + #[ResponseBody(status: 201)] + #[RequestParam('name', 'string')] + #[RequestParam('email', 'email')] + #[PreAuthorize("hasAuthority('USER_CREATE')")] + public function createUser(): array { + $name = $this->getParamVal('name'); + $email = $this->getParamVal('email'); + + if (empty($name)) { + throw new BadRequestException('Name is required'); + } + + return [ + 'message' => 'User created successfully', + 'user' => [ + 'id' => rand(1000, 9999), + 'name' => $name, + 'email' => $email, + 'created_at' => date('Y-m-d H:i:s') + ] + ]; + } + + #[PutMapping] + #[ResponseBody] + #[RequestParam('id', 'int')] + #[RequestParam('name', 'string', true)] + #[RequestParam('email', 'email', true)] + #[PreAuthorize("hasAuthority('USER_UPDATE')")] + public function updateUser(): array { + $id = $this->getParamVal('id'); + $name = $this->getParamVal('name'); + $email = $this->getParamVal('email'); + + if ($id === 404) { + throw new NotFoundException('User not found'); + } + + $updates = array_filter([ + 'name' => $name, + 'email' => $email + ]); + + return [ + 'message' => 'User updated successfully', + 'user' => [ + 'id' => $id, + 'updates' => $updates, + 'updated_at' => date('Y-m-d H:i:s') + ] + ]; + } + + #[DeleteMapping] + #[ResponseBody(status: 204)] + #[RequestParam('id', 'int')] + #[PreAuthorize("hasRole('ADMIN')")] + public function deleteUser(): null { + $id = $this->getParamVal('id'); + + if ($id === 404) { + throw new NotFoundException('User not found'); + } + + // Simulate deletion + return null; // Auto-converts to 204 No Content + } +} diff --git a/examples/GetRandomService.php b/examples/GetRandomService.php index ae48520..7e1cc8d 100644 --- a/examples/GetRandomService.php +++ b/examples/GetRandomService.php @@ -1,43 +1,33 @@ setRequestMethods([ - RequestMethod::GET, - RequestMethod::POST - ]); + parent::__construct('get-random'); + $this->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::OPTIONAL => true, + ParamOption::DESCRIPTION => 'Minimum value for the random number.' ], 'max' => [ ParamOption::TYPE => ParamType::INT, - ParamOption::OPTIONAL => true + ParamOption::OPTIONAL => true, + ParamOption::DESCRIPTION => 'Maximum value for the random number.' ] ]); } - 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 isAuthorized(): bool { + return true; } public function processRequest() { @@ -49,6 +39,9 @@ public function processRequest() { } else { $random = rand(); } - $this->sendResponse($random); + + $this->sendResponse('Random number generated', 'success', 200, [ + 'number' => $random + ]); } } diff --git a/examples/HelloWithAuthService.php b/examples/HelloWithAuthService.php index 40fdd2a..2585571 100644 --- a/examples/HelloWithAuthService.php +++ b/examples/HelloWithAuthService.php @@ -2,13 +2,13 @@ require 'loader.php'; -use WebFiori\Http\AbstractWebService; +use WebFiori\Http\WebService; use WebFiori\Http\ParamOption; use WebFiori\Http\ParamType; use WebFiori\Http\RequestMethod; use WebFiori\Http\ResponseMessage; -class HelloWithAuthService extends AbstractWebService { +class HelloWithAuthService extends WebService { public function __construct() { parent::__construct('hello-with-auth'); $this->setRequestMethods([RequestMethod::GET]); @@ -20,7 +20,7 @@ public function __construct() { ] ]); } - public function isAuthorized() { + public function isAuthorized(): bool { //Change default response message to custom one ResponseMessage::set('401', 'Not authorized to use this API.'); diff --git a/examples/HelloWorldService.php b/examples/HelloWorldService.php index 44233e8..23dbd9c 100644 --- a/examples/HelloWorldService.php +++ b/examples/HelloWorldService.php @@ -1,25 +1,27 @@ setRequestMethods([RequestMethod::GET]); + $this->setDescription('Returns a greeting message.'); $this->addParameters([ 'my-name' => [ ParamOption::TYPE => ParamType::STRING, - ParamOption::OPTIONAL => true + ParamOption::OPTIONAL => true, + ParamOption::DESCRIPTION => 'Your name to include in the greeting.' ] ]); } - public function isAuthorized() { + public function isAuthorized(): bool { + return true; } public function processRequest() { @@ -27,7 +29,8 @@ public function processRequest() { if ($name !== null) { $this->sendResponse("Hello '$name'."); + } else { + $this->sendResponse('Hello World!'); } - $this->sendResponse('Hello World!'); } } diff --git a/examples/ProductController.php b/examples/ProductController.php new file mode 100644 index 0000000..760edaf --- /dev/null +++ b/examples/ProductController.php @@ -0,0 +1,91 @@ +getParamVal('page'); + $limit = $this->getParamVal('limit'); + + $this->sendResponse('Products retrieved', 'success', 200, [ + 'page' => $page, + 'limit' => $limit, + 'products' => [] + ]); + } + + #[PostMapping] + #[RequestParam('name', 'string', false, null, 'Product name')] + #[RequestParam('price', 'float', false, null, 'Product price')] + #[RequestParam('category', 'string', true, 'General', 'Product category')] + public function createProduct() { + $name = $this->getParamVal('name'); + $price = $this->getParamVal('price'); + $category = $this->getParamVal('category'); + + $this->sendResponse('Product created', 'success', 201, [ + 'id' => 123, + 'name' => $name, + 'price' => $price, + 'category' => $category + ]); + } + + #[PutMapping] + #[RequestParam('id', 'int', false, null, 'Product ID')] + #[RequestParam('name', 'string', true)] + #[RequestParam('price', 'float', true)] + public function updateProduct() { + $id = $this->getParamVal('id'); + $name = $this->getParamVal('name'); + $price = $this->getParamVal('price'); + + $this->sendResponse('Product updated', 'success', 200, [ + 'id' => $id, + 'name' => $name, + 'price' => $price + ]); + } + + #[DeleteMapping] + #[RequestParam('id', 'int', false, null, 'Product ID to delete')] + public function deleteProduct() { + $id = $this->getParamVal('id'); + $this->sendResponse('Product deleted', 'success', 200, ['id' => $id]); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + + switch ($method) { + case \WebFiori\Http\RequestMethod::GET: + $this->getProducts(); + break; + case \WebFiori\Http\RequestMethod::POST: + $this->createProduct(); + break; + case \WebFiori\Http\RequestMethod::PUT: + $this->updateProduct(); + break; + case \WebFiori\Http\RequestMethod::DELETE: + $this->deleteProduct(); + break; + } + } +} \ No newline at end of file diff --git a/examples/UserController.php b/examples/UserController.php new file mode 100644 index 0000000..391766c --- /dev/null +++ b/examples/UserController.php @@ -0,0 +1,58 @@ +sendResponse('Retrieved all users', 'success', 200, ['users' => []]); + } + + #[PostMapping] + public function createUser() { + $this->sendResponse('User created', 'success', 201, ['id' => 123]); + } + + #[PutMapping] + public function updateUser() { + $this->sendResponse('User updated', 'success', 200); + } + + #[DeleteMapping] + public function deleteUser() { + $this->sendResponse('User deleted', 'success', 200); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + + switch ($method) { + case \WebFiori\Http\RequestMethod::GET: + $this->getUsers(); + break; + case \WebFiori\Http\RequestMethod::POST: + $this->createUser(); + break; + case \WebFiori\Http\RequestMethod::PUT: + $this->updateUser(); + break; + case \WebFiori\Http\RequestMethod::DELETE: + $this->deleteUser(); + break; + default: + $this->sendResponse('Method not allowed', 'error', 405); + } + } +} diff --git a/examples/index.php b/examples/index.php index 0bbe98e..537e386 100644 --- a/examples/index.php +++ b/examples/index.php @@ -4,14 +4,32 @@ require 'HelloWorldService.php'; require 'GetRandomService.php'; require 'HelloWithAuthService.php'; +require 'CompleteApiDemo.php'; +require 'ProductController.php'; +require 'AuthenticatedController.php'; +require 'UserController.php'; +require 'AuthTestService.php'; use HelloWorldService; use GetRandomService; use HelloWithAuthService; use WebFiori\Http\WebServicesManager; +use WebFiori\Http\SecurityContext; + +// Set up authentication context +SecurityContext::setCurrentUser(['id' => 1, 'name' => 'Demo User']); +SecurityContext::setRoles(['USER', 'ADMIN']); +SecurityContext::setCurrentUser(['id' => 1, 'name' => 'Demo User']); +SecurityContext::setRoles(['USER', 'ADMIN']); +SecurityContext::setAuthorities(['USER_CREATE', 'USER_UPDATE', 'USER_DELETE']); $manager = new WebServicesManager(); $manager->addService(new HelloWorldService()); $manager->addService(new GetRandomService()); $manager->addService(new HelloWithAuthService()); +$manager->addService(new CompleteApiDemo()); +$manager->addService(new ProductController()); +$manager->addService(new AuthenticatedController()); +$manager->addService(new UserController()); +$manager->addService(new AuthTestService()); $manager->process(); diff --git a/examples/loader.php b/examples/loader.php index 3290d0c..a83718c 100644 --- a/examples/loader.php +++ b/examples/loader.php @@ -4,4 +4,4 @@ ini_set('display_errors', 1); error_reporting(-1); -require_once '../vendor/autoload.php'; +require_once __DIR__ . '/../vendor/autoload.php'; diff --git a/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php b/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php new file mode 100644 index 0000000..9345a3f --- /dev/null +++ b/tests/WebFiori/Tests/Http/AuthenticationAnnotationTest.php @@ -0,0 +1,88 @@ +assertFalse($service->isAuthRequired()); + } + + public function testSecurityContextAuthentication() { + // Test unauthenticated state + $this->assertFalse(SecurityContext::isAuthenticated()); + + // Set user and roles + SecurityContext::setCurrentUser(new TestUser(1, ['ADMIN', 'USER'], ['USER_CREATE', 'USER_READ'], true)); + + $this->assertTrue(SecurityContext::isAuthenticated()); + $this->assertTrue(SecurityContext::hasRole('ADMIN')); + $this->assertTrue(SecurityContext::hasAuthority('USER_CREATE')); + $this->assertFalse(SecurityContext::hasRole('GUEST')); + } + + public function testMethodLevelAuthorization() { + $service = new SecureService(); + + // Test public method (AllowAnonymous) + $_GET['action'] = 'public'; + $this->assertTrue($service->checkMethodAuthorization()); + + // Test private method without auth (RequiresAuth) + $_GET['action'] = 'private'; + $this->assertFalse($service->checkMethodAuthorization()); + + // Test private method with auth + SecurityContext::setCurrentUser(new TestUser(1, [], [], true)); + $this->assertTrue($service->checkMethodAuthorization()); + + // Test admin method without admin role + $_GET['action'] = 'admin'; + SecurityContext::setRoles(['USER']); + $this->assertFalse($service->checkMethodAuthorization()); + + // Test admin method with admin role + SecurityContext::setRoles(['ADMIN']); + $this->assertTrue($service->checkMethodAuthorization()); + + // Test authority-based method + $_GET['action'] = 'create'; + SecurityContext::setAuthorities(['USER_READ']); + $this->assertFalse($service->checkMethodAuthorization()); + + SecurityContext::setAuthorities(['USER_CREATE']); + $this->assertTrue($service->checkMethodAuthorization()); + } + + public function testSecurityExpressions() { + SecurityContext::clear(); + + // Test without authentication + $this->assertFalse(SecurityContext::evaluateExpression("hasRole('ADMIN')")); + $this->assertFalse(SecurityContext::evaluateExpression('isAuthenticated()')); + $this->assertTrue(SecurityContext::evaluateExpression('permitAll()')); + + // Test with authentication and roles + SecurityContext::setCurrentUser(new TestUser(1, ['ADMIN'], ['USER_CREATE'], true)); + + $this->assertTrue(SecurityContext::evaluateExpression("hasRole('ADMIN')")); + $this->assertFalse(SecurityContext::evaluateExpression("hasRole('GUEST')")); + $this->assertTrue(SecurityContext::evaluateExpression("hasAuthority('USER_CREATE')")); + $this->assertTrue(SecurityContext::evaluateExpression('isAuthenticated()')); + } + + protected function tearDown(): void { + SecurityContext::clear(); + unset($_GET['action']); + } +} diff --git a/tests/WebFiori/Tests/Http/ExceptionHandlingTest.php b/tests/WebFiori/Tests/Http/ExceptionHandlingTest.php new file mode 100644 index 0000000..7087c63 --- /dev/null +++ b/tests/WebFiori/Tests/Http/ExceptionHandlingTest.php @@ -0,0 +1,109 @@ +assertEquals(404, $notFound->getStatusCode()); + $this->assertEquals('error', $notFound->getResponseType()); + $this->assertEquals('Resource not found', $notFound->getMessage()); + + $badRequest = new BadRequestException('Invalid input'); + $this->assertEquals(400, $badRequest->getStatusCode()); + $this->assertEquals('error', $badRequest->getResponseType()); + + $unauthorized = new UnauthorizedException('Login required'); + $this->assertEquals(401, $unauthorized->getStatusCode()); + $this->assertEquals('error', $unauthorized->getResponseType()); + + $forbidden = new ForbiddenException('Access denied'); + $this->assertEquals(403, $forbidden->getStatusCode()); + $this->assertEquals('error', $forbidden->getResponseType()); + } + + public function testExceptionDefaults() { + $notFound = new NotFoundException(); + $this->assertEquals('Not Found', $notFound->getMessage()); + + $badRequest = new BadRequestException(); + $this->assertEquals('Bad Request', $badRequest->getMessage()); + + $unauthorized = new UnauthorizedException(); + $this->assertEquals('Unauthorized', $unauthorized->getMessage()); + + $forbidden = new ForbiddenException(); + $this->assertEquals('Forbidden', $forbidden->getMessage()); + } + + public function testServiceExceptionHandling() { + $service = new ExceptionTestService(); + + // Test that method has ResponseBody annotation + $this->assertTrue($service->hasResponseBodyAnnotation('getUser')); + + // Test exception throwing with test parameter + $_GET['test_id'] = 404; + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('User not found'); + $service->getUser(); + } + + public function testDifferentExceptionTypes() { + $service = new ExceptionTestService(); + + // Test BadRequestException + $_GET['test_id'] = 400; + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Invalid user ID'); + $service->getUser(); + } + + public function testUnauthorizedException() { + $service = new ExceptionTestService(); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('Authentication required'); + $service->createUser(); + } + + public function testGenericException() { + $service = new ExceptionTestService(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Generic error'); + $service->getError(); + } + + public function testHandleExceptionMethod() { + $service = new ExceptionTestService(); + $exception = new NotFoundException('Test not found'); + + // Test the handleException method directly + $reflection = new \ReflectionClass($service); + $method = $reflection->getMethod('handleException'); + $method->setAccessible(true); + + // The method should not throw an exception + $this->expectNotToPerformAssertions(); + $method->invoke($service, $exception); + } + + protected function tearDown(): void { + unset($_GET['action']); + unset($_GET['test_id']); + unset($_SERVER['REQUEST_METHOD']); + } +} diff --git a/tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php b/tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php new file mode 100644 index 0000000..3b85632 --- /dev/null +++ b/tests/WebFiori/Tests/Http/FullAnnotationIntegrationTest.php @@ -0,0 +1,83 @@ +setName('manual-service'); + $service->addRequestMethod(\WebFiori\Http\RequestMethod::GET); + $service->addParameters([ + 'test' => [ + \WebFiori\Http\ParamOption::TYPE => ParamType::STRING + ] + ]); + + $this->assertEquals('manual-service', $service->getName()); + $this->assertContains(\WebFiori\Http\RequestMethod::GET, $service->getRequestMethods()); + $this->assertNotNull($service->getParameterByName('test')); + } + + public function testAnnotationOverridesManualConfiguration() { + $service = new #[\WebFiori\Http\Annotations\RestController('annotated-override')] + class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('manual-name'); // This should be overridden + } + + #[\WebFiori\Http\Annotations\GetMapping] + public function getData() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->assertEquals('annotated-override', $service->getName()); + $this->assertContains(\WebFiori\Http\RequestMethod::GET, $service->getRequestMethods()); + } + + public function testMixedConfigurationApproach() { + $service = new #[\WebFiori\Http\Annotations\RestController('mixed-service')] + class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct(); + // Add manual configuration after annotation processing + $this->addRequestMethod(\WebFiori\Http\RequestMethod::PATCH); + $this->addParameters([ + 'manual_param' => [ + \WebFiori\Http\ParamOption::TYPE => ParamType::STRING + ] + ]); + } + + #[\WebFiori\Http\Annotations\PostMapping] + #[\WebFiori\Http\Annotations\RequestParam('annotated_param', 'int')] + public function createData() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->assertEquals('mixed-service', $service->getName()); + + $methods = $service->getRequestMethods(); + $this->assertContains(\WebFiori\Http\RequestMethod::POST, $methods); // From annotation + $this->assertContains(\WebFiori\Http\RequestMethod::PATCH, $methods); // Manual addition + + $this->assertNotNull($service->getParameterByName('annotated_param', 'POST')); // From annotation + $this->assertNotNull($service->getParameterByName('manual_param')); // Manual addition + } +} diff --git a/tests/WebFiori/Tests/Http/HttpCookieTest.php b/tests/WebFiori/Tests/Http/HttpCookieTest.php index 4e8402a..6304a03 100644 --- a/tests/WebFiori/Tests/Http/HttpCookieTest.php +++ b/tests/WebFiori/Tests/Http/HttpCookieTest.php @@ -96,7 +96,8 @@ public function testRemainingTime00() { $cookie->setExpires(1); $this->assertEquals(60, $cookie->getRemainingTime()); sleep(3); - $this->assertEquals(57, $cookie->getRemainingTime()); + $this->assertGreaterThanOrEqual(55, $cookie->getRemainingTime()); + $this->assertLessThanOrEqual(57, $cookie->getRemainingTime()); } /** * @test diff --git a/tests/WebFiori/Tests/Http/ManagerInfoServiceTest.php b/tests/WebFiori/Tests/Http/ManagerInfoServiceTest.php new file mode 100644 index 0000000..6e6531b --- /dev/null +++ b/tests/WebFiori/Tests/Http/ManagerInfoServiceTest.php @@ -0,0 +1,33 @@ +addService($service); + + $this->assertEquals('api-docs', $service->getName()); + $this->assertStringContainsString('information about all end points', $service->getDescription()); + $this->assertContains(RequestMethod::GET, $service->getRequestMethods()); + + // Test that processRequest sends JSON + $this->assertNotNull($service->getManager()); + $this->assertSame($manager, $service->getManager()); + } +} + +class TestManagerInfoService extends ManagerInfoService { + public function isAuthorized(): bool { + return true; + } +} diff --git a/tests/WebFiori/Tests/Http/MethodMappingTest.php b/tests/WebFiori/Tests/Http/MethodMappingTest.php new file mode 100644 index 0000000..160bb45 --- /dev/null +++ b/tests/WebFiori/Tests/Http/MethodMappingTest.php @@ -0,0 +1,59 @@ +getRequestMethods(); + + $this->assertContains(RequestMethod::GET, $methods); + $this->assertContains(RequestMethod::POST, $methods); + $this->assertCount(2, $methods); + } + + public function testAllMethodMappings() { + $service = new AllMethodsService(); + $methods = $service->getRequestMethods(); + + $this->assertContains(RequestMethod::GET, $methods); + $this->assertContains(RequestMethod::POST, $methods); + $this->assertContains(RequestMethod::PUT, $methods); + $this->assertContains(RequestMethod::DELETE, $methods); + $this->assertCount(4, $methods); + } + + public function testServiceWithoutMethodAnnotations() { + $service = new class extends \WebFiori\Http\WebService { + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $methods = $service->getRequestMethods(); + $this->assertEmpty($methods); + } + + public function testMixedAnnotationAndManualConfiguration() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('mixed-service'); + $this->addRequestMethod(RequestMethod::PATCH); // Manual addition + } + + #[\WebFiori\Http\Annotations\GetMapping] + public function getData() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $methods = $service->getRequestMethods(); + $this->assertContains(RequestMethod::GET, $methods); // From annotation + $this->assertContains(RequestMethod::PATCH, $methods); // Manual addition + } +} diff --git a/tests/WebFiori/Tests/Http/OpenAPITest.php b/tests/WebFiori/Tests/Http/OpenAPITest.php new file mode 100644 index 0000000..5560736 --- /dev/null +++ b/tests/WebFiori/Tests/Http/OpenAPITest.php @@ -0,0 +1,525 @@ +assertEquals('My API', $info->getTitle()); + $this->assertEquals('1.0.0', $info->getVersion()); + + $info->setSummary('API Summary'); + $this->assertEquals('API Summary', $info->getSummary()); + + $info->setDescription('API Description'); + $this->assertEquals('API Description', $info->getDescription()); + + $info->setTermsOfService('https://example.com/terms'); + $this->assertEquals('https://example.com/terms', $info->getTermsOfService()); + + $contact = new ContactObj(); + $info->setContact($contact); + $this->assertSame($contact, $info->getContact()); + + $license = new LicenseObj('MIT'); + $info->setLicense($license); + $this->assertSame($license, $info->getLicense()); + + $json = $info->toJSON(); + $this->assertEquals('My API', $json->get('title')); + $this->assertEquals('1.0.0', $json->get('version')); + $this->assertEquals('API Summary', $json->get('summary')); + } + + /** + * @test + */ + public function testLicenseObj() { + $license = new LicenseObj('Apache 2.0'); + $this->assertEquals('Apache 2.0', $license->getName()); + + $license->setIdentifier('Apache-2.0'); + $this->assertEquals('Apache-2.0', $license->getIdentifier()); + $this->assertNull($license->getUrl()); + + $license->setUrl('https://www.apache.org/licenses/LICENSE-2.0.html'); + $this->assertEquals('https://www.apache.org/licenses/LICENSE-2.0.html', $license->getUrl()); + $this->assertNull($license->getIdentifier()); + + $json = $license->toJSON(); + $this->assertEquals('Apache 2.0', $json->get('name')); + $this->assertEquals('https://www.apache.org/licenses/LICENSE-2.0.html', $json->get('url')); + } + + /** + * @test + */ + public function testContactObj() { + $contact = new ContactObj(); + $this->assertNull($contact->getName()); + + $contact->setName('API Support'); + $this->assertEquals('API Support', $contact->getName()); + + $contact->setUrl('https://example.com/support'); + $this->assertEquals('https://example.com/support', $contact->getUrl()); + + $contact->setEmail('support@example.com'); + $this->assertEquals('support@example.com', $contact->getEmail()); + + $json = $contact->toJSON(); + $this->assertEquals('API Support', $json->get('name')); + $this->assertEquals('https://example.com/support', $json->get('url')); + $this->assertEquals('support@example.com', $json->get('email')); + } + + /** + * @test + */ + public function testOpenAPIObj() { + $info = new InfoObj('Test API', '2.0.0'); + $openapi = new OpenAPIObj($info); + + $this->assertEquals('3.1.0', $openapi->getOpenapi()); + $this->assertSame($info, $openapi->getInfo()); + + $openapi->setOpenapi('3.0.0'); + $this->assertEquals('3.0.0', $openapi->getOpenapi()); + + $paths = new PathsObj(); + $openapi->setPaths($paths); + $this->assertSame($paths, $openapi->getPaths()); + + $json = $openapi->toJSON(); + $this->assertEquals('3.0.0', $json->get('openapi')); + $this->assertNotNull($json->get('info')); + } + + /** + * @test + */ + public function testServerObj() { + $server = new ServerObj('https://api.example.com'); + $this->assertEquals('https://api.example.com', $server->getUrl()); + + $server->setDescription('Production server'); + $this->assertEquals('Production server', $server->getDescription()); + + $json = $server->toJSON(); + $this->assertEquals('https://api.example.com', $json->get('url')); + $this->assertEquals('Production server', $json->get('description')); + } + + /** + * @test + */ + public function testTagObj() { + $tag = new TagObj('users'); + $this->assertEquals('users', $tag->getName()); + + $tag->setDescription('User operations'); + $this->assertEquals('User operations', $tag->getDescription()); + + $externalDocs = new ExternalDocObj('https://docs.example.com'); + $tag->setExternalDocs($externalDocs); + $this->assertSame($externalDocs, $tag->getExternalDocs()); + + $json = $tag->toJSON(); + $this->assertEquals('users', $json->get('name')); + $this->assertEquals('User operations', $json->get('description')); + } + + /** + * @test + */ + public function testExternalDocObj() { + $doc = new ExternalDocObj('https://docs.example.com'); + $this->assertEquals('https://docs.example.com', $doc->getUrl()); + + $doc->setDescription('External documentation'); + $this->assertEquals('External documentation', $doc->getDescription()); + + $json = $doc->toJSON(); + $this->assertEquals('https://docs.example.com', $json->get('url')); + $this->assertEquals('External documentation', $json->get('description')); + } + + /** + * @test + */ + public function testPathsObj() { + $paths = new PathsObj(); + $this->assertEmpty($paths->getPaths()); + + $pathItem = new PathItemObj(); + $paths->addPath('/users', $pathItem); + + $allPaths = $paths->getPaths(); + $this->assertCount(1, $allPaths); + $this->assertSame($pathItem, $allPaths['/users']); + + $json = $paths->toJSON(); + $this->assertNotNull($json->get('/users')); + } + + /** + * @test + */ + public function testPathItemObj() { + $pathItem = new PathItemObj(); + $this->assertNull($pathItem->getGet()); + $this->assertNull($pathItem->getPost()); + + $responses = new ResponsesObj(); + $responses->addResponse('200', 'Success'); + + $getOp = new OperationObj($responses); + $pathItem->setGet($getOp); + $this->assertSame($getOp, $pathItem->getGet()); + + $postOp = new OperationObj($responses); + $pathItem->setPost($postOp); + $this->assertSame($postOp, $pathItem->getPost()); + + $putOp = new OperationObj($responses); + $pathItem->setPut($putOp); + $this->assertSame($putOp, $pathItem->getPut()); + + $deleteOp = new OperationObj($responses); + $pathItem->setDelete($deleteOp); + $this->assertSame($deleteOp, $pathItem->getDelete()); + + $patchOp = new OperationObj($responses); + $pathItem->setPatch($patchOp); + $this->assertSame($patchOp, $pathItem->getPatch()); + + $json = $pathItem->toJSON(); + $this->assertNotNull($json->get('get')); + $this->assertNotNull($json->get('post')); + $this->assertNotNull($json->get('put')); + $this->assertNotNull($json->get('delete')); + $this->assertNotNull($json->get('patch')); + } + + /** + * @test + */ + public function testOperationObj() { + $responses = new ResponsesObj(); + $responses->addResponse('200', 'Success'); + + $operation = new OperationObj($responses); + $this->assertSame($responses, $operation->getResponses()); + + $newResponses = new ResponsesObj(); + $operation->setResponses($newResponses); + $this->assertSame($newResponses, $operation->getResponses()); + + $json = $operation->toJSON(); + $this->assertNotNull($json->get('responses')); + } + + /** + * @test + */ + public function testResponsesObj() { + $responses = new ResponsesObj(); + $this->assertEmpty($responses->getResponses()); + + $responses->addResponse('200', 'Success'); + $responses->addResponse('404', 'Not found'); + + $allResponses = $responses->getResponses(); + $this->assertCount(2, $allResponses); + $this->assertInstanceOf(ResponseObj::class, $allResponses['200']); + $this->assertEquals('Success', $allResponses['200']->getDescription()); + + $json = $responses->toJSON(); + $this->assertNotNull($json->get('200')); + $this->assertNotNull($json->get('404')); + } + + /** + * @test + */ + public function testResponseObj() { + $response = new ResponseObj('Operation successful'); + $this->assertEquals('Operation successful', $response->getDescription()); + + $response->setDescription('Updated description'); + $this->assertEquals('Updated description', $response->getDescription()); + + $json = $response->toJSON(); + $this->assertEquals('Updated description', $json->get('description')); + } + + /** + * @test + */ + public function testParameterObj() { + $param = new ParameterObj('userId', 'path'); + $this->assertEquals('userId', $param->getName()); + $this->assertEquals('path', $param->getIn()); + + $param->setDescription('User ID parameter'); + $this->assertEquals('User ID parameter', $param->getDescription()); + + $param->setRequired(true); + $this->assertTrue($param->getRequired()); + + $param->setDeprecated(true); + $this->assertTrue($param->getDeprecated()); + + $param->setAllowEmptyValue(true); + $this->assertTrue($param->getAllowEmptyValue()); + + $param->setStyle('simple'); + $this->assertEquals('simple', $param->getStyle()); + + $param->setExplode(false); + $this->assertFalse($param->getExplode()); + + $param->setAllowReserved(true); + $this->assertTrue($param->getAllowReserved()); + + $param->setSchema(['type' => 'integer']); + $this->assertEquals(['type' => 'integer'], $param->getSchema()); + + $param->setExample(123); + $this->assertEquals(123, $param->getExample()); + + $param->setExamples(['example1' => ['value' => 123]]); + $this->assertEquals(['example1' => ['value' => 123]], $param->getExamples()); + + $json = $param->toJSON(); + $this->assertEquals('userId', $json->get('name')); + $this->assertEquals('path', $json->get('in')); + $this->assertEquals('User ID parameter', $json->get('description')); + } + + /** + * @test + */ + public function testHeaderObj() { + $header = new HeaderObj(); + $this->assertNull($header->getDescription()); + + $header->setDescription('Custom header'); + $this->assertEquals('Custom header', $header->getDescription()); + + $header->setRequired(true); + $this->assertTrue($header->getRequired()); + + $header->setDeprecated(true); + $this->assertTrue($header->getDeprecated()); + + $header->setStyle('simple'); + $this->assertEquals('simple', $header->getStyle()); + + $header->setExplode(true); + $this->assertTrue($header->getExplode()); + + $header->setSchema(['type' => 'string']); + $this->assertEquals(['type' => 'string'], $header->getSchema()); + + $header->setExample('example-value'); + $this->assertEquals('example-value', $header->getExample()); + + $header->setExamples(['ex1' => ['value' => 'test']]); + $this->assertEquals(['ex1' => ['value' => 'test']], $header->getExamples()); + + $json = $header->toJSON(); + $this->assertEquals('Custom header', $json->get('description')); + } + + /** + * @test + */ + public function testMediaTypeObj() { + $mediaType = new MediaTypeObj(); + $this->assertNull($mediaType->getSchema()); + + $mediaType->setSchema(['type' => 'object']); + $this->assertEquals(['type' => 'object'], $mediaType->getSchema()); + + $json = $mediaType->toJSON(); + $this->assertNotNull($json->get('schema')); + } + + /** + * @test + */ + public function testReferenceObj() { + $ref = new ReferenceObj('#/components/schemas/User'); + $this->assertEquals('#/components/schemas/User', $ref->getRef()); + + $ref->setSummary('User reference'); + $this->assertEquals('User reference', $ref->getSummary()); + + $ref->setDescription('Reference to User schema'); + $this->assertEquals('Reference to User schema', $ref->getDescription()); + + $json = $ref->toJSON(); + $this->assertEquals('#/components/schemas/User', $json->get('$ref')); + $this->assertEquals('User reference', $json->get('summary')); + } + + /** + * @test + */ + public function testSecuritySchemeObj() { + $scheme = new SecuritySchemeObj('http'); + $this->assertEquals('http', $scheme->getType()); + + $scheme->setDescription('HTTP Basic Auth'); + $this->assertEquals('HTTP Basic Auth', $scheme->getDescription()); + + $scheme->setName('Authorization'); + $this->assertEquals('Authorization', $scheme->getName()); + + $scheme->setIn('header'); + $this->assertEquals('header', $scheme->getIn()); + + $scheme->setScheme('basic'); + $this->assertEquals('basic', $scheme->getScheme()); + + $scheme->setBearerFormat('JWT'); + $this->assertEquals('JWT', $scheme->getBearerFormat()); + + $flows = new OAuthFlowsObj(); + $scheme->setFlows($flows); + $this->assertSame($flows, $scheme->getFlows()); + + $scheme->setOpenIdConnectUrl('https://example.com/.well-known/openid-configuration'); + $this->assertEquals('https://example.com/.well-known/openid-configuration', $scheme->getOpenIdConnectUrl()); + + $json = $scheme->toJSON(); + $this->assertEquals('http', $json->get('type')); + $this->assertEquals('HTTP Basic Auth', $json->get('description')); + } + + /** + * @test + */ + public function testOAuthFlowObj() { + $flow = new OAuthFlowObj(); + $this->assertEmpty($flow->getScopes()); + + $flow->setAuthorizationUrl('https://example.com/oauth/authorize'); + $this->assertEquals('https://example.com/oauth/authorize', $flow->getAuthorizationUrl()); + + $flow->setTokenUrl('https://example.com/oauth/token'); + $this->assertEquals('https://example.com/oauth/token', $flow->getTokenUrl()); + + $flow->setRefreshUrl('https://example.com/oauth/refresh'); + $this->assertEquals('https://example.com/oauth/refresh', $flow->getRefreshUrl()); + + $flow->addScope('read', 'Read access'); + $flow->addScope('write', 'Write access'); + + $scopes = $flow->getScopes(); + $this->assertCount(2, $scopes); + $this->assertEquals('Read access', $scopes['read']); + + $json = $flow->toJSON(); + $this->assertNotNull($json->get('scopes')); + } + + /** + * @test + */ + public function testOAuthFlowsObj() { + $flows = new OAuthFlowsObj(); + $this->assertNull($flows->getImplicit()); + + $implicit = new OAuthFlowObj(); + $flows->setImplicit($implicit); + $this->assertSame($implicit, $flows->getImplicit()); + + $password = new OAuthFlowObj(); + $flows->setPassword($password); + $this->assertSame($password, $flows->getPassword()); + + $clientCredentials = new OAuthFlowObj(); + $flows->setClientCredentials($clientCredentials); + $this->assertSame($clientCredentials, $flows->getClientCredentials()); + + $authCode = new OAuthFlowObj(); + $flows->setAuthorizationCode($authCode); + $this->assertSame($authCode, $flows->getAuthorizationCode()); + + $json = $flows->toJSON(); + $this->assertNotNull($json->get('implicit')); + $this->assertNotNull($json->get('password')); + } + + /** + * @test + */ + public function testSecurityRequirementObj() { + $requirement = new SecurityRequirementObj(); + $this->assertEmpty($requirement->getRequirements()); + + $requirement->addRequirement('api_key', []); + $requirement->addRequirement('oauth2', ['read', 'write']); + + $reqs = $requirement->getRequirements(); + $this->assertCount(2, $reqs); + $this->assertEmpty($reqs['api_key']); + $this->assertEquals(['read', 'write'], $reqs['oauth2']); + + $json = $requirement->toJSON(); + $this->assertNotNull($json->get('api_key')); + $this->assertNotNull($json->get('oauth2')); + } + + /** + * @test + */ + public function testComponentsObj() { + $components = new ComponentsObj(); + $this->assertEmpty($components->getSchemas()); + $this->assertEmpty($components->getSecuritySchemes()); + + $components->addSchema('User', ['type' => 'object']); + $schemas = $components->getSchemas(); + $this->assertCount(1, $schemas); + $this->assertEquals(['type' => 'object'], $schemas['User']); + + $securityScheme = new SecuritySchemeObj('http'); + $components->addSecurityScheme('basicAuth', $securityScheme); + $schemes = $components->getSecuritySchemes(); + $this->assertCount(1, $schemes); + $this->assertSame($securityScheme, $schemes['basicAuth']); + + $json = $components->toJSON(); + $this->assertNotNull($json->get('schemas')); + $this->assertNotNull($json->get('securitySchemes')); + } +} diff --git a/tests/WebFiori/Tests/Http/ParameterMappingTest.php b/tests/WebFiori/Tests/Http/ParameterMappingTest.php new file mode 100644 index 0000000..860cb01 --- /dev/null +++ b/tests/WebFiori/Tests/Http/ParameterMappingTest.php @@ -0,0 +1,57 @@ +getParameterByName('id', 'GET'); + $service->getParameterByName('email', 'POST'); + + $parameters = $service->getParameters(); + + $this->assertCount(4, $parameters); // id, name, email, age + + // Check 'id' parameter + $idParam = $service->getParameterByName('id'); + $this->assertNotNull($idParam); + $this->assertEquals(ParamType::INT, $idParam->getType()); + $this->assertFalse($idParam->isOptional()); + $this->assertEquals('User ID', $idParam->getDescription()); + + // Check 'name' parameter + $nameParam = $service->getParameterByName('name'); + $this->assertNotNull($nameParam); + $this->assertEquals(ParamType::STRING, $nameParam->getType()); + $this->assertTrue($nameParam->isOptional()); + $this->assertEquals('Anonymous', $nameParam->getDefault()); + + // Check 'email' parameter + $emailParam = $service->getParameterByName('email'); + $this->assertNotNull($emailParam); + $this->assertEquals(ParamType::EMAIL, $emailParam->getType()); + $this->assertFalse($emailParam->isOptional()); + + // Check 'age' parameter + $ageParam = $service->getParameterByName('age'); + $this->assertNotNull($ageParam); + $this->assertEquals(ParamType::INT, $ageParam->getType()); + $this->assertTrue($ageParam->isOptional()); + $this->assertEquals(18, $ageParam->getDefault()); + } + + public function testHttpMethodsFromParameterAnnotations() { + $service = new ParameterMappedService(); + $methods = $service->getRequestMethods(); + + $this->assertContains(\WebFiori\Http\RequestMethod::GET, $methods); + $this->assertContains(\WebFiori\Http\RequestMethod::POST, $methods); + $this->assertCount(2, $methods); + } +} diff --git a/tests/WebFiori/Tests/Http/RequestParameterTest.php b/tests/WebFiori/Tests/Http/RequestParameterTest.php index 1962c57..4822eab 100644 --- a/tests/WebFiori/Tests/Http/RequestParameterTest.php +++ b/tests/WebFiori/Tests/Http/RequestParameterTest.php @@ -598,8 +598,7 @@ public function testSetMinLength05() { */ public function testToJson00($reqParam) { $reqParam->setDescription('Test Parameter.'); - $this->assertEquals('{"name":"a-parameter","type":"string","description":"Test Parameter.",' - .'"is-optional":false,"default-value":null,"min-val":null,"max-val":null,"min-length":null,"max-length":null}',$reqParam->toJSON().''); + $this->assertEquals('{"name":"a-parameter","in":"query","required":true,"description":"Test Parameter.","schema":{"type":"string"}}',$reqParam->toJSON().''); } /** * @test @@ -608,8 +607,7 @@ public function testToJson00($reqParam) { */ public function testToJson01($reqParam) { $reqParam->setDescription('Test Parameter.'); - $this->assertEquals('{"name":"valid","type":"integer","description":"Test Parameter.",' - .'"is-optional":true,"default-value":null,"min-val":'.~PHP_INT_MAX.',"max-val":'.PHP_INT_MAX.',"min-length":null,"max-length":null}',$reqParam->toJSON().''); + $this->assertEquals('{"name":"valid","in":"query","required":false,"description":"Test Parameter.","schema":{"type":"integer","minimum":'.~PHP_INT_MAX.',"maximum":'.PHP_INT_MAX.'}}',$reqParam->toJSON().''); } /** * @@ -662,5 +660,17 @@ public function testToString01() { ." Minimum Length => 'null',\n" ." Maximum Length => 'null'\n" ."]\n",$rp.''); + + } + /** + * @test + */ + public function testRequestMethod00() { + $rp = new RequestParameter('user-id','integer'); + $this->assertEquals([], $rp->getMethods()); + $rp->addMethod('get'); + $this->assertEquals(['GET'], $rp->getMethods()); + $rp->addMethods(['geT', 'PoSt ']); + $this->assertEquals(['GET', 'POST'], $rp->getMethods()); } } diff --git a/tests/WebFiori/Tests/Http/ResponseBodyTest.php b/tests/WebFiori/Tests/Http/ResponseBodyTest.php new file mode 100644 index 0000000..23734cd --- /dev/null +++ b/tests/WebFiori/Tests/Http/ResponseBodyTest.php @@ -0,0 +1,153 @@ +getTargetMethod(); + $this->assertEquals('getArrayData', $targetMethod); + + // Test ResponseBody annotation detection + $this->assertTrue($service->hasResponseBodyAnnotation('getArrayData')); + + // Test return value processing + $result = $service->getArrayData(); + $this->assertIsArray($result); + $this->assertArrayHasKey('users', $result); + } + + public function testStringReturnValue() { + $service = new ResponseBodyTestService(); + $_SERVER['REQUEST_METHOD'] = 'GET'; + + // Test specific method + $this->assertTrue($service->hasResponseBodyAnnotation('getStringData')); + + $result = $service->getStringData(); + $this->assertIsString($result); + $this->assertEquals('Resource created successfully', $result); + } + + public function testNullReturnValue() { + $service = new ResponseBodyTestService(); + $_SERVER['REQUEST_METHOD'] = 'POST'; + + // Test method discovery for POST + $targetMethod = $service->getTargetMethod(); + $this->assertEquals('deleteData', $targetMethod); + + $result = $service->deleteData(); + $this->assertNull($result); + } + + public function testObjectReturnValue() { + $service = new ResponseBodyTestService(); + + // Test specific method + $this->assertTrue($service->hasResponseBodyAnnotation('getObjectData')); + + $result = $service->getObjectData(); + $this->assertIsObject($result); + $this->assertTrue(property_exists($result, 'message'), "The object should have the attribute 'message'."); + } + + public function testMethodWithoutResponseBody() { + $service = new ResponseBodyTestService(); + + // Should not have ResponseBody annotation + $this->assertFalse($service->hasResponseBodyAnnotation('getManualData')); + } + + public function testMethodWithParameters() { + $service = new ResponseBodyTestService(); + + // Test that method has ResponseBody annotation + $this->assertTrue($service->hasResponseBodyAnnotation('createUser')); + + // Test method has POST mapping + $_SERVER['REQUEST_METHOD'] = 'POST'; + $targetMethod = $service->getTargetMethod(); + $this->assertContains($targetMethod, ['deleteData', 'createUser']); // Either POST method + } + + public function testMixedServiceWithAuthentication() { + $service = new MixedResponseService(); + + // Test methods have correct annotations + $this->assertTrue($service->hasResponseBodyAnnotation('getSecureData')); + $this->assertTrue($service->hasResponseBodyAnnotation('getPublicData')); + $this->assertFalse($service->hasResponseBodyAnnotation('traditionalMethod')); + + // Test with authentication + SecurityContext::setCurrentUser(new TestUser(1, ['USER'])); + + // The service should be authorized since we set up proper authentication + $this->assertTrue($service->checkMethodAuthorization()); + } + + public function testLegacyServiceCompatibility() { + $service = new LegacyService(); + + // Should find the GET method + $_SERVER['REQUEST_METHOD'] = 'GET'; + $targetMethod = $service->getTargetMethod(); + $this->assertEquals('getData', $targetMethod); + + // Method should not have ResponseBody annotation + $this->assertFalse($service->hasResponseBodyAnnotation('getData')); + } + + public function testProcessWithAutoHandling() { + $service = new ResponseBodyTestService(); + $_SERVER['REQUEST_METHOD'] = 'GET'; + + // Test the auto-processing logic + $targetMethod = $service->getTargetMethod(); + $hasResponseBody = $service->hasResponseBodyAnnotation($targetMethod); + + $this->assertTrue($hasResponseBody); + $this->assertEquals('getArrayData', $targetMethod); + } + + public function testResponseBodyAnnotationConfiguration() { + $service = new ResponseBodyTestService(); + + // Test default ResponseBody annotation + $reflection = new \ReflectionMethod($service, 'getArrayData'); + $attributes = $reflection->getAttributes(\WebFiori\Http\Annotations\ResponseBody::class); + $this->assertNotEmpty($attributes); + + $responseBody = $attributes[0]->newInstance(); + $this->assertEquals(200, $responseBody->status); + $this->assertEquals('success', $responseBody->type); + + // Test custom ResponseBody annotation + $reflection = new \ReflectionMethod($service, 'getStringData'); + $attributes = $reflection->getAttributes(\WebFiori\Http\Annotations\ResponseBody::class); + $responseBody = $attributes[0]->newInstance(); + $this->assertEquals(201, $responseBody->status); + $this->assertEquals('created', $responseBody->type); + } + + protected function tearDown(): void { + SecurityContext::clear(); + unset($_GET['action']); + } +} diff --git a/tests/WebFiori/Tests/Http/RestControllerTest.php b/tests/WebFiori/Tests/Http/RestControllerTest.php new file mode 100644 index 0000000..9ffcc7a --- /dev/null +++ b/tests/WebFiori/Tests/Http/RestControllerTest.php @@ -0,0 +1,163 @@ +assertEquals('annotated-service', $service->getName()); + $this->assertEquals('A service configured via annotations', $service->getDescription()); + $this->assertEquals(['GET', 'DELETE'], $service->getRequestMethods()); + } + + public function testNonAnnotatedService() { + $service = new NonAnnotatedService(); + $this->assertEquals('non-annotated', $service->getName()); + $this->assertEquals('A traditional service', $service->getDescription()); + } + + public function testAnnotationWithEmptyName() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('fallback-name'); + } + }; + + $this->assertEquals('fallback-name', $service->getName()); + } + + public function testAnnotationWithoutFallback() { + $service = new class extends \WebFiori\Http\WebService { + }; + + $this->assertEquals('new-service', $service->getName()); + } + + public function testAnnotatedServiceWithManager() { + $manager = new WebServicesManager(); + $service = new AnnotatedService(); + $manager->addService($service); + + $retrievedService = $manager->getServiceByName('annotated-service'); + $this->assertNotNull($retrievedService); + $this->assertEquals('A service configured via annotations', $retrievedService->getDescription()); + + $this->assertEquals('{'.self::NL + . ' "message":"Method Not Allowed.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":405'.self::NL + . '}', $this->postRequest($manager, 'annotated-service')); + + $this->assertEquals('{'.self::NL + . ' "message":"Hi user!",'.self::NL + . ' "type":"success",'.self::NL + . ' "http-code":200'.self::NL + . '}', $this->getRequest($manager, 'annotated-service')); + + $this->assertEquals('{'.self::NL + . ' "message":"Hi Ibrahim!",'.self::NL + . ' "type":"success",'.self::NL + . ' "http-code":200'.self::NL + . '}', $this->getRequest($manager, 'annotated-service', [ + 'name' => 'Ibrahim' + ])); + } + public function testAnnotatedServiceMethodNotAllowed() { + $manager = new WebServicesManager(); + $service = new AnnotatedService(); + $manager->addService($service); + + $this->assertEquals('{'.self::NL + . ' "message":"Method Not Allowed.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":405'.self::NL + . '}', $this->postRequest($manager, 'annotated-service')); + + } + public function testAnnotatedGet() { + $manager = new WebServicesManager(); + $service = new AnnotatedService(); + $manager->addService($service); + + $retrievedService = $manager->getServiceByName('annotated-service'); + $this->assertNotNull($retrievedService); + $this->assertEquals('A service configured via annotations', $retrievedService->getDescription()); + + $this->assertEquals('{'.self::NL + . ' "message":"Hi user!",'.self::NL + . ' "type":"success",'.self::NL + . ' "http-code":200'.self::NL + . '}', $this->getRequest($manager, 'annotated-service')); + + $this->assertEquals('{'.self::NL + . ' "message":"Hi Ibrahim!",'.self::NL + . ' "type":"success",'.self::NL + . ' "http-code":200'.self::NL + . '}', $this->getRequest($manager, 'annotated-service', [ + 'name' => 'Ibrahim' + ])); + } + public function testAnnotatedDelete() { + $manager = new WebServicesManager(); + $service = new AnnotatedService(); + $manager->addService($service); + //Missing param + $this->assertEquals('{'.self::NL + . ' "message":"The following required parameter(s) where missing from the request body: \'id\'.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":404,'.self::NL + . ' "more-info":{'.self::NL + . ' "missing":['.self::NL + . ' "id"'.self::NL + . ' ]'.self::NL + . ' }'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service')); + //No auth user + $this->assertEquals('{'.self::NL + . ' "message":"Not Authorized.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":401'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service', [ + 'id' => 1 + ])); + //User with no roles + $this->assertEquals('{'.self::NL + . ' "message":"Not Authorized.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":401'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service', [ + 'id' => 1 + ], [], new TestUser(1, [''], [''], true))); + //user with no authorites + $this->assertEquals('{'.self::NL + . ' "message":"Not Authorized.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":401'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service', [ + 'id' => 1 + ], [], new TestUser(1, ['ADMIN'], [''], true))); + // Inactive user + $this->assertEquals('{'.self::NL + . ' "message":"Not Authorized.",'.self::NL + . ' "type":"error",'.self::NL + . ' "http-code":401'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service', [ + 'id' => 1 + ], [], new TestUser(1, ['ADMIN'], ['USER_DELETE'], false))); + //valid user + $this->assertEquals('{'.self::NL + . ' "message":"Delete user with ID: 1",'.self::NL + . ' "type":"success",'.self::NL + . ' "http-code":200'.self::NL + . '}', $this->deleteRequest($manager, 'annotated-service', [ + 'id' => 1 + ], [], new TestUser(1, ['ADMIN'], ['USER_DELETE'], true))); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/AbstractNumbersService.php b/tests/WebFiori/Tests/Http/TestServices/AbstractNumbersService.php index ca7f33a..b11abf6 100644 --- a/tests/WebFiori/Tests/Http/TestServices/AbstractNumbersService.php +++ b/tests/WebFiori/Tests/Http/TestServices/AbstractNumbersService.php @@ -1,7 +1,7 @@ addParameter(new RequestParameter('pass','string')); } - public function isAuthorized() { + public function isAuthorized(): bool { $inputs = $this->getInputs(); if ($inputs instanceof \webfiori\json\Json) { $pass = $inputs->get('pass'); diff --git a/tests/WebFiori/Tests/Http/TestServices/AllMethodsService.php b/tests/WebFiori/Tests/Http/TestServices/AllMethodsService.php new file mode 100644 index 0000000..ba2d1df --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AllMethodsService.php @@ -0,0 +1,33 @@ +sendResponse('All methods service'); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php b/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php new file mode 100644 index 0000000..098cec6 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AnnotatedService.php @@ -0,0 +1,36 @@ +getParamVal('name'); + + if ($name !== null) { + return "Hi ".$name.'!'; + } + return "Hi user!"; + } + #[DeleteMapping] + #[ResponseBody] + #[RequestParam('id', ParamType::INT)] + #[PreAuthorize("isAuthenticated() && hasRole('ADMIN') && hasAuthority('USER_DELETE')")] + public function delete() { + $id = $this->getParamVal('id'); + return "Delete user with ID: ".$id; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ClassLevelAuthService.php b/tests/WebFiori/Tests/Http/TestServices/ClassLevelAuthService.php new file mode 100644 index 0000000..2a4ad81 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ClassLevelAuthService.php @@ -0,0 +1,19 @@ +sendResponse('Public service - no auth required'); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/CreateUserProfileServiceV2.php b/tests/WebFiori/Tests/Http/TestServices/CreateUserProfileServiceV2.php new file mode 100644 index 0000000..003785f --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/CreateUserProfileServiceV2.php @@ -0,0 +1,72 @@ +addRequestMethod(RequestMethod::POST); + $this->addRequestMethod(RequestMethod::GET); + $this->addParameters([ + 'id' => [ + ParamOption::TYPE => ParamType::INT, + ParamOption::METHODS => [RequestMethod::GET] + ], + 'name' => [ + ParamOption::TYPE => ParamType::STRING, + ParamOption::METHODS => [RequestMethod::POST] + ], + 'username' => [ + ParamOption::TYPE => ParamType::STRING, + ParamOption::METHODS => [RequestMethod::POST] + ], + 'x' => [ + ParamOption::TYPE => ParamType::INT, + ParamOption::OPTIONAL => true, + ParamOption::DEFAULT => 3 + ] + ]); + } + public function isAuthorized(): bool { + return true; + } + public function processRequest() { + + } + public function processGET() { + $j = new Json(); + $userObj = new TestUserObj(); + $userObj->setFullName('Ibx'); + $userObj->setId($this->getParamVal('id')); + $j->addObject('user', $userObj); + $this->send('application/json', $j); + } + public function processPOST() { + try { + $userObj = $this->getObject('not\\Exist', [ + 'name' => 'setFullName' + ]); + } catch (Exception $ex) { + $userObj = $this->getObject(TestUserObj::class, [ + 'name' => 'setFullName', + 'x' => 'setId' + ]); + } + $j = new Json(); + $j->addObject('user', $userObj); + $this->send('application/json', $j); + } + +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ExceptionTestService.php b/tests/WebFiori/Tests/Http/TestServices/ExceptionTestService.php new file mode 100644 index 0000000..42ccd87 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ExceptionTestService.php @@ -0,0 +1,79 @@ +getParamVal('id'); + + // For testing, check if id is set via test + if (!$id && isset($_GET['test_id'])) { + $id = (int)$_GET['test_id']; + } + + if ($id === 404) { + throw new NotFoundException('User not found'); + } + + if ($id === 400) { + throw new BadRequestException('Invalid user ID'); + } + + return ['user' => ['id' => $id, 'name' => 'Test User']]; + } + + #[PostMapping] + #[ResponseBody] + public function createUser(): array { + throw new UnauthorizedException('Authentication required'); + } + + #[GetMapping] + #[ResponseBody] + public function getError(): array { + throw new \Exception('Generic error'); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $action = $_GET['action'] ?? 'get'; + switch ($action) { + case 'get': + $this->getUser(); + break; + case 'create': + $this->createUser(); + break; + case 'error': + $this->getError(); + break; + } + } + + protected function getCurrentProcessingMethod(): ?string { + $action = $_GET['action'] ?? 'get'; + return match($action) { + 'get' => 'getUser', + 'create' => 'createUser', + 'error' => 'getError', + default => null + }; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/IntegrationTestService.php b/tests/WebFiori/Tests/Http/TestServices/IntegrationTestService.php new file mode 100644 index 0000000..aa4b8cd --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/IntegrationTestService.php @@ -0,0 +1,26 @@ + 'Auto-processing via WebServicesManager']; + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + // This should not be called when using auto-processing + $this->sendResponse('Manual processing fallback', 200, 'info'); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/LegacyService.php b/tests/WebFiori/Tests/Http/TestServices/LegacyService.php new file mode 100644 index 0000000..9861305 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/LegacyService.php @@ -0,0 +1,24 @@ +sendResponse('Legacy service response', 200, 'success', ['legacy' => true]); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $this->getData(); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/MappedMethodsService.php b/tests/WebFiori/Tests/Http/TestServices/MappedMethodsService.php new file mode 100644 index 0000000..d23bf4b --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/MappedMethodsService.php @@ -0,0 +1,34 @@ +sendResponse('GET users'); + } + + #[PostMapping] + public function createUser() { + $this->sendResponse('POST user'); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $method = $this->getManager()->getRequestMethod(); + if ($method === \WebFiori\Http\RequestMethod::GET) { + $this->getUsers(); + } elseif ($method === \WebFiori\Http\RequestMethod::POST) { + $this->createUser(); + } + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/MixedResponseService.php b/tests/WebFiori/Tests/Http/TestServices/MixedResponseService.php new file mode 100644 index 0000000..878fc5e --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/MixedResponseService.php @@ -0,0 +1,60 @@ + 'data', 'user' => 'authenticated']; + } + + // ResponseBody method without authentication + #[GetMapping] + #[ResponseBody] + #[AllowAnonymous] + public function getPublicData(): array { + return ['public' => 'data', 'access' => 'open']; + } + + // Traditional method (no ResponseBody) + #[PostMapping] + #[AllowAnonymous] + public function traditionalMethod(): void { + $this->sendResponse('Traditional method response', 200, 'success', ['method' => 'traditional']); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $action = $_GET['action'] ?? 'traditional'; + if ($action === 'traditional') { + $this->traditionalMethod(); + } else { + $this->sendResponse('Unknown action', 400, 'error'); + } + } + + protected function getCurrentProcessingMethod(): ?string { + $action = $_GET['action'] ?? 'traditional'; + return match($action) { + 'secure' => 'getSecureData', + 'public' => 'getPublicData', + 'traditional' => 'traditionalMethod', + default => null + }; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/MulNubmersService.php b/tests/WebFiori/Tests/Http/TestServices/MulNubmersService.php index 8080339..0386417 100644 --- a/tests/WebFiori/Tests/Http/TestServices/MulNubmersService.php +++ b/tests/WebFiori/Tests/Http/TestServices/MulNubmersService.php @@ -2,14 +2,14 @@ namespace WebFiori\Tests\Http\TestServices; -use WebFiori\Http\AbstractWebService; +use WebFiori\Http\WebService; use WebFiori\Http\RequestMethod; use WebFiori\Http\RequestParameter; /** * * @author Ibrahim */ -class MulNubmersService extends AbstractWebService { +class MulNubmersService extends WebService { public function __construct() { parent::__construct('mul-two-integers'); $this->setDescription('Returns a JSON string that has the multiplication of two integers.'); @@ -19,10 +19,15 @@ public function __construct() { $this->addParameter(new RequestParameter('second-number', 'integer')); } + public function isAuthorized(): bool { + return true; + } + public function isAuthorizedGET() { if ($this->getParamVal('first-number') < 0) { return false; } + return true; } public function processGet() { diff --git a/tests/WebFiori/Tests/Http/TestServices/NoAuthService.php b/tests/WebFiori/Tests/Http/TestServices/NoAuthService.php index a109560..80f5c36 100644 --- a/tests/WebFiori/Tests/Http/TestServices/NoAuthService.php +++ b/tests/WebFiori/Tests/Http/TestServices/NoAuthService.php @@ -2,20 +2,20 @@ namespace WebFiori\Tests\Http\TestServices; -use WebFiori\Http\AbstractWebService; +use WebFiori\Http\WebService; use WebFiori\Http\RequestMethod; /** * Description of NoAuthService * * @author Ibrahim */ -class NoAuthService extends AbstractWebService { +class NoAuthService extends WebService { public function __construct() { parent::__construct('ok-service'); $this->setIsAuthRequired(false); $this->addRequestMethod(RequestMethod::GET); } - public function isAuthorized() { + public function isAuthorized(): bool { return false; } diff --git a/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php b/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php new file mode 100644 index 0000000..13679bb --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/NonAnnotatedService.php @@ -0,0 +1,11 @@ +setDescription('A traditional service'); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/NotImplService.php b/tests/WebFiori/Tests/Http/TestServices/NotImplService.php index 4180ab5..e88fd17 100644 --- a/tests/WebFiori/Tests/Http/TestServices/NotImplService.php +++ b/tests/WebFiori/Tests/Http/TestServices/NotImplService.php @@ -2,7 +2,7 @@ namespace WebFiori\Tests\Http\TestServices; -use WebFiori\Http\AbstractWebService; +use WebFiori\Http\WebService; use WebFiori\Http\RequestMethod; /** @@ -10,13 +10,13 @@ * * @author Ibrahim */ -class NotImplService extends AbstractWebService { +class NotImplService extends WebService { public function __construct() { parent::__construct('not-implemented'); $this->addRequestMethod(RequestMethod::POST); } - public function isAuthorized() { - + public function isAuthorized(): bool { + return true; } public function processRequest() { diff --git a/tests/WebFiori/Tests/Http/TestServices/ParameterMappedService.php b/tests/WebFiori/Tests/Http/TestServices/ParameterMappedService.php new file mode 100644 index 0000000..2ba39af --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ParameterMappedService.php @@ -0,0 +1,43 @@ +getParamVal('id'); + $name = $this->getParamVal('name'); + $this->sendResponse("User $id: $name"); + } + + #[PostMapping] + #[RequestParam('email', 'email', false)] + #[RequestParam('age', 'int', true, 18)] + public function createUser() { + $email = $this->getParamVal('email'); + $age = $this->getParamVal('age'); + $this->sendResponse("Created user: $email, age: $age"); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + if ($method === \WebFiori\Http\RequestMethod::GET) { + $this->getUser(); + } elseif ($method === \WebFiori\Http\RequestMethod::POST) { + $this->createUser(); + } + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php b/tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php new file mode 100644 index 0000000..31053ef --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ResponseBodyTestService.php @@ -0,0 +1,87 @@ + [['id' => 1, 'name' => 'John'], ['id' => 2, 'name' => 'Jane']]]; + } + + // Test 2: Return string with custom status + #[GetMapping] + #[ResponseBody(status: 201, type: 'created')] + public function getStringData(): string { + return 'Resource created successfully'; + } + + // Test 3: Return null (should be empty response) + #[PostMapping] + #[ResponseBody(status: 204)] + public function deleteData(): ?object { + // Simulate deletion + return null; + } + + // Test 4: Return object + #[GetMapping] + #[ResponseBody] + public function getObjectData(): object { + return (object)['message' => 'Hello World', 'timestamp' => time()]; + } + + // Test 5: Method without ResponseBody (manual handling) + #[GetMapping] + #[RequestParam('manual', 'string', true, 'false')] + public function getManualData(): void { + $manual = $this->getParamVal('manual'); + $this->sendResponse('Manual response: ' . $manual, 200, 'success'); + } + + // Test 6: Method with parameters and ResponseBody + #[PostMapping] + #[ResponseBody] + #[RequestParam('name', 'string')] + #[RequestParam('age', 'int', true, 25)] + public function createUser(): array { + return [ + 'user' => [ + 'name' => $this->getParamVal('name'), + 'age' => $this->getParamVal('age'), + 'created_at' => date('Y-m-d H:i:s') + ] + ]; + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + // This should not be called for ResponseBody methods + $this->sendResponse('Fallback processRequest called', 200, 'info'); + } + + protected function getCurrentProcessingMethod(): ?string { + $action = $_GET['action'] ?? 'array'; + return match($action) { + 'array' => 'getArrayData', + 'string' => 'getStringData', + 'null' => 'deleteData', + 'object' => 'getObjectData', + 'manual' => 'getManualData', + 'create' => 'createUser', + default => null + }; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/SampleServicesManager.php b/tests/WebFiori/Tests/Http/TestServices/SampleServicesManager.php index 4916228..75b3cb3 100644 --- a/tests/WebFiori/Tests/Http/TestServices/SampleServicesManager.php +++ b/tests/WebFiori/Tests/Http/TestServices/SampleServicesManager.php @@ -17,6 +17,7 @@ public function __construct() { $this->addService(new SumNumbersService()); $this->addService(new GetUserProfileService()); + $this->addService(new CreateUserProfileServiceV2()); $this->addService(new CreateUserProfileService()); $this->addService(new MulNubmersService()); } diff --git a/tests/WebFiori/Tests/Http/TestServices/SecureService.php b/tests/WebFiori/Tests/Http/TestServices/SecureService.php new file mode 100644 index 0000000..5e98b18 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/SecureService.php @@ -0,0 +1,80 @@ +sendResponse('Public data - no auth required'); + } + + #[GetMapping] + #[RequiresAuth] + public function getPrivateData() { + $user = SecurityContext::getCurrentUser(); + $this->sendResponse('Private data for: ' . ($user['name'] ?? 'unknown')); + } + + #[PostMapping] + #[PreAuthorize("hasRole('ADMIN')")] + public function adminOnly() { + $this->sendResponse('Admin-only operation'); + } + + #[PostMapping] + #[PreAuthorize("hasAuthority('USER_CREATE')")] + public function createUser() { + $this->sendResponse('User created'); + } + + public function isAuthorized(): bool { + return true; // Default fallback + } + + public function processRequest() { + if (!$this->checkMethodAuthorization()) { + $this->sendResponse('Unauthorized', 'error', 401); + return; + } + + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + $action = $_GET['action'] ?? 'public'; + + switch ($action) { + case 'public': + $this->getPublicData(); + break; + case 'private': + $this->getPrivateData(); + break; + case 'admin': + $this->adminOnly(); + break; + case 'create': + $this->createUser(); + break; + } + } + + protected function getCurrentProcessingMethod(): ?string { + $action = $_GET['action'] ?? 'public'; + return match($action) { + 'public' => 'getPublicData', + 'private' => 'getPrivateData', + 'admin' => 'adminOnly', + 'create' => 'createUser', + default => null + }; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/TestServiceObj.php b/tests/WebFiori/Tests/Http/TestServices/TestServiceObj.php index 4153c8a..ed43921 100644 --- a/tests/WebFiori/Tests/Http/TestServices/TestServiceObj.php +++ b/tests/WebFiori/Tests/Http/TestServices/TestServiceObj.php @@ -2,19 +2,19 @@ namespace WebFiori\Tests\Http\TestServices; -use WebFiori\Http\AbstractWebService; +use WebFiori\Http\WebService; /** * Description of TestServiceObj * * @author Ibrahim */ -class TestServiceObj extends AbstractWebService { +class TestServiceObj extends WebService { public function __construct($name) { parent::__construct($name); } //put your code here - public function isAuthorized() { - return parent::isAuthorized(); + public function isAuthorized(): bool { + return true; } public function processRequest() { diff --git a/tests/WebFiori/Tests/Http/TestUser.php b/tests/WebFiori/Tests/Http/TestUser.php new file mode 100644 index 0000000..51190e4 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestUser.php @@ -0,0 +1,37 @@ +id = $id; + $this->roles = $roles; + $this->authorities = $authorities; + $this->active = $active; + } + + public function getId(): int|string { + return $this->id; + } + + public function getRoles(): array { + return $this->roles; + } + + public function getAuthorities(): array { + return $this->authorities; + } + + public function isActive(): bool { + return $this->active; + } +} diff --git a/tests/WebFiori/Tests/Http/WebServiceTest.php b/tests/WebFiori/Tests/Http/WebServiceTest.php index 8f3a393..535f905 100644 --- a/tests/WebFiori/Tests/Http/WebServiceTest.php +++ b/tests/WebFiori/Tests/Http/WebServiceTest.php @@ -16,7 +16,7 @@ class WebServiceTest extends TestCase { public function testGetAuthHeaders00() { $service = new TestServiceObj('Hello'); $this->assertNull($service->getAuthHeader()); - $this->assertNull($service->isAuthorized()); + $this->assertTrue($service->isAuthorized()); } /** * @@ -108,7 +108,8 @@ public function testAddParameters02() { 'password' => [ ParamOption::OPTIONAL => true, ParamOption::DEFAULT => 1234, - ParamOption::TYPE => 'integer' + ParamOption::TYPE => 'integer', + ParamOption::METHODS => 'get' ] ]); $this->assertEquals(2,count($action->getParameters())); @@ -119,6 +120,7 @@ public function testAddParameters02() { $this->assertEquals('integer', $param2->getType()); $this->assertTrue($param2->isOptional()); $this->assertEquals(1234, $param2->getDefault()); + $this->assertEquals(['GET'], $param2->getMethods()); } /** * @test @@ -252,79 +254,27 @@ public function testRemoveRequestMethod($action) { */ public function testToJson00() { $action = new TestServiceObj('login'); - $this->assertEquals('' - .'{"name":"login",' - .'"since":"1.0.0",' - .'"description":"",' - .'"request-methods":[],' - .'"parameters":[],' - .'"responses":[]}',$action->toJSON().''); + $this->assertEquals('{}',$action->toJSON().''); $action->setSince('1.0.1'); $action->setDescription('Allow the user to login to the system.'); - $this->assertEquals('' - .'{"name":"login",' - .'"since":"1.0.1",' - .'"description":"Allow the user to login to the system.",' - .'"request-methods":[],' - .'"parameters":[],' - .'"responses":[]}',$action->toJSON().''); + $this->assertEquals('{}',$action->toJSON().''); $action->setRequestMethods([RequestMethod::GET, RequestMethod::POST, RequestMethod::PUT]); $this->assertEquals('' - .'{"name":"login",' - .'"since":"1.0.1",' - .'"description":"Allow the user to login to the system.",' - .'"request-methods":["GET","POST","PUT"],' - .'"parameters":[],' - .'"responses":[]}',$action->toJSON().''); + .'{"get":{"responses":{"200":{"description":"Successful operation"}}},' + .'"post":{"responses":{"200":{"description":"Successful operation"}}},' + .'"put":{"responses":{"200":{"description":"Successful operation"}}}}',$action->toJSON().''); $action->removeRequestMethod('put'); $action->addParameter(new RequestParameter('username')); $this->assertEquals('' - .'{"name":"login",' - .'"since":"1.0.1",' - .'"description":"Allow the user to login to the system.",' - .'"request-methods":["GET","POST"],' - .'"parameters":[' - .'{"name":"username",' - .'"type":"string",' - .'"description":null,' - .'"is-optional":false,' - .'"default-value":null,' - .'"min-val":null,' - .'"max-val":null,' - .'"min-length":null,' - .'"max-length":null}' - .'],' - .'"responses":[]}',$action->toJSON().''); + .'{"get":{"responses":{"200":{"description":"Successful operation"}}},' + .'"post":{"responses":{"200":{"description":"Successful operation"}}}}',$action->toJSON().''); $action->addParameter(new RequestParameter('password', 'integer')); $action->getParameterByName('password')->setDescription('The password of the user.'); $action->getParameterByName('password')->setMinValue(1000000); $this->assertEquals('' - .'{"name":"login",' - .'"since":"1.0.1",' - .'"description":"Allow the user to login to the system.",' - .'"request-methods":["GET","POST"],' - .'"parameters":[' - .'{"name":"username",' - .'"type":"string",' - .'"description":null,' - .'"is-optional":false,' - .'"default-value":null,' - .'"min-val":null,' - .'"max-val":null,' - .'"min-length":null,' - .'"max-length":null},' - .'{"name":"password",' - .'"type":"integer",' - .'"description":"The password of the user.",' - .'"is-optional":false,' - .'"default-value":null,' - .'"min-val":1000000,' - .'"max-val":'.PHP_INT_MAX.',' - .'"min-length":null,' - .'"max-length":null}' - .'],' - .'"responses":[]}',$action->toJSON().''); + .'{"get":{"responses":{"200":{"description":"Successful operation"}}},' + .'"post":{"responses":{"200":{"description":"Successful operation"}}}}',$action->toJSON().''); } /** * @test @@ -335,26 +285,7 @@ public function testToString00() { $action->addParameter(new RequestParameter('user-id', 'integer')); $action->getParameterByName('user-id')->setDescription('The ID of the user.'); $action->setDescription('Returns a JSON string which holds user profile info.'); - $this->assertEquals("APIAction[\n" - ." Name => 'get-user',\n" - ." Description => 'Returns a JSON string which holds user profile info.',\n" - ." Since => '1.0.0',\n" - ." Request Methods => [\n" - ." GET\n" - ." ],\n" - ." Parameters => [\n" - ." user-id => [\n" - ." Type => 'integer',\n" - ." Description => 'The ID of the user.',\n" - ." Is Optional => 'false',\n" - ." Default => 'null',\n" - ." Minimum Value => '".~PHP_INT_MAX."',\n" - ." Maximum Value => '".PHP_INT_MAX."'\n" - ." ]\n" - ." ],\n" - ." Responses Descriptions => [\n" - ." ]\n" - ."]\n",$action.''); + $this->assertEquals('{"get":{"responses":{"200":{"description":"Successful operation"}}}}',$action.''); } /** * @test @@ -367,38 +298,119 @@ public function testToString01() { $action->getParameterByName('username')->setDescription('The username of the user.'); $action->getParameterByName('email')->setDescription('The email address of the user.'); $action->setDescription('Adds new user profile to the system.'); - $action->addResponseDescription('If the user is added, a 201 HTTP response is send with a JSON string that contains user ID.'); - $action->addResponseDescription('If a user is already exist wich has the given email, a 404 code is sent back.'); - $this->assertEquals("APIAction[\n" - ." Name => 'add-user',\n" - ." Description => 'Adds new user profile to the system.',\n" - ." Since => '1.0.0',\n" - ." Request Methods => [\n" - ." POST,\n" - ." PUT\n" - ." ],\n" - ." Parameters => [\n" - ." username => [\n" - ." Type => 'string',\n" - ." Description => 'The username of the user.',\n" - ." Is Optional => 'false',\n" - ." Default => 'null',\n" - ." Minimum Value => 'null',\n" - ." Maximum Value => 'null'\n" - ." ],\n" - ." email => [\n" - ." Type => 'string',\n" - ." Description => 'The email address of the user.',\n" - ." Is Optional => 'false',\n" - ." Default => 'null',\n" - ." Minimum Value => 'null',\n" - ." Maximum Value => 'null'\n" - ." ]\n" - ." ],\n" - ." Responses Descriptions => [\n" - ." Response #0 => 'If the user is added, a 201 HTTP response is send with a JSON string that contains user ID.',\n" - ." Response #1 => 'If a user is already exist wich has the given email, a 404 code is sent back.'\n" - ." ]\n" - ."]\n",$action.''); + $action->addResponse(RequestMethod::POST, '201', 'User created successfully'); + $action->addResponse(RequestMethod::PUT, '200', 'User updated successfully'); + $this->assertEquals('{"post":{"responses":{"201":{"description":"User created successfully"}}},' + .'"put":{"responses":{"200":{"description":"User updated successfully"}}}}',$action.''); + } + /** + * @test + */ + public function testDuplicateGetMappingThrowsException() { + $this->expectException(\WebFiori\Http\Exceptions\DuplicateMappingException::class); + $this->expectExceptionMessage('HTTP method GET is mapped to multiple methods: getUsers, getUsersAgain'); + + new class extends \WebFiori\Http\WebService { + #[\WebFiori\Http\Annotations\GetMapping] + public function getUsers() {} + + #[\WebFiori\Http\Annotations\GetMapping] + public function getUsersAgain() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + } + + /** + * @test + */ + public function testDuplicatePostMappingThrowsException() { + $this->expectException(\WebFiori\Http\Exceptions\DuplicateMappingException::class); + $this->expectExceptionMessage('HTTP method POST is mapped to multiple methods: createUser, addUser'); + + new class extends \WebFiori\Http\WebService { + #[\WebFiori\Http\Annotations\PostMapping] + public function createUser() {} + + #[\WebFiori\Http\Annotations\PostMapping] + public function addUser() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + } + + /** + * @test + */ + public function testDuplicateMixedMappingsThrowsException() { + $this->expectException(\WebFiori\Http\Exceptions\DuplicateMappingException::class); + $this->expectExceptionMessage('HTTP method PUT is mapped to multiple methods: updateUser, modifyUser'); + + new class extends \WebFiori\Http\WebService { + #[\WebFiori\Http\Annotations\GetMapping] + public function getUser() {} + + #[\WebFiori\Http\Annotations\PutMapping] + public function updateUser() {} + + #[\WebFiori\Http\Annotations\PutMapping] + public function modifyUser() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + } + + /** + * @test + */ + public function testMultipleDuplicateGetMappingThrowsException() { + $this->expectException(\WebFiori\Http\Exceptions\DuplicateMappingException::class); + $this->expectExceptionMessage('HTTP method GET is mapped to multiple methods: getUsers, getUsersAgain, fetchUsers'); + + new class extends \WebFiori\Http\WebService { + #[\WebFiori\Http\Annotations\GetMapping] + public function getUsers() {} + + #[\WebFiori\Http\Annotations\GetMapping] + public function getUsersAgain() {} + + #[\WebFiori\Http\Annotations\GetMapping] + public function fetchUsers() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + } + + /** + * @test + */ + public function testNoDuplicateMappingsDoesNotThrowException() { + $service = new class extends \WebFiori\Http\WebService { + #[\WebFiori\Http\Annotations\GetMapping] + public function getUser() {} + + #[\WebFiori\Http\Annotations\PostMapping] + public function createUser() {} + + #[\WebFiori\Http\Annotations\PutMapping] + public function updateUser() {} + + #[\WebFiori\Http\Annotations\DeleteMapping] + public function deleteUser() {} + + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $methods = $service->getRequestMethods(); + $this->assertContains('GET', $methods); + $this->assertContains('POST', $methods); + $this->assertContains('PUT', $methods); + $this->assertContains('DELETE', $methods); + $this->assertCount(4, $methods); } } diff --git a/tests/WebFiori/Tests/Http/WebServicesManagerIntegrationTest.php b/tests/WebFiori/Tests/Http/WebServicesManagerIntegrationTest.php new file mode 100644 index 0000000..b8e74f5 --- /dev/null +++ b/tests/WebFiori/Tests/Http/WebServicesManagerIntegrationTest.php @@ -0,0 +1,109 @@ +addService($service); + + // Test that service has processWithAutoHandling method + $this->assertTrue(method_exists($service, 'processWithAutoHandling')); + + // Test that service has ResponseBody methods + $this->assertTrue($service->hasResponseBodyAnnotation('getData')); + } + + public function testManagerProcessesAutoHandlingServices() { + $service = new IntegrationTestService(); + + // Test that service has ResponseBody methods + $this->assertTrue($service->hasResponseBodyAnnotation('getData')); + + // Test method return value + $result = $service->getData(); + $this->assertIsArray($result); + $this->assertEquals('Auto-processing via WebServicesManager', $result['message']); + } + + public function testLegacyServiceStillWorks() { + $service = new LegacyService(); + + // Legacy service should not have ResponseBody methods + $this->assertFalse($service->hasResponseBodyAnnotation('getData')); + + // Should have traditional methods + $this->assertTrue(method_exists($service, 'processRequest')); + } + + public function testMixedServiceTypes() { + $manager = new WebServicesManager(); + + // Add both new and legacy services + $manager->addService(new IntegrationTestService()); + $manager->addService(new LegacyService()); + + // Both should be registered + $this->assertNotNull($manager->getServiceByName('integration-test')); + $this->assertNotNull($manager->getServiceByName('legacy-service')); + } + + public function testResponseBodyWithExceptionHandling() { + $service = new ResponseBodyTestService(); + + // Test that service has ResponseBody methods + $_SERVER['REQUEST_METHOD'] = 'GET'; + $targetMethod = $service->getTargetMethod(); + $this->assertTrue($service->hasResponseBodyAnnotation($targetMethod)); + + // Test method return value + $result = $service->getArrayData(); + $this->assertIsArray($result); + $this->assertArrayHasKey('users', $result); + } + + public function testServiceMethodDiscovery() { + $service = new ResponseBodyTestService(); + + // Test GET method discovery + $_SERVER['REQUEST_METHOD'] = 'GET'; + $targetMethod = $service->getTargetMethod(); + $this->assertEquals('getArrayData', $targetMethod); + + // Test POST method discovery + $_SERVER['REQUEST_METHOD'] = 'POST'; + $targetMethod = $service->getTargetMethod(); + $this->assertContains($targetMethod, ['deleteData', 'createUser']); + } + + public function testBackwardCompatibility() { + // Test that all existing functionality still works + $legacyService = new LegacyService(); + $newService = new IntegrationTestService(); + + // Both should have processRequest method + $this->assertTrue(method_exists($legacyService, 'processRequest')); + $this->assertTrue(method_exists($newService, 'processRequest')); + + // Both should have processWithAutoHandling (inherited from WebService) + $this->assertTrue(method_exists($legacyService, 'processWithAutoHandling')); + $this->assertTrue(method_exists($newService, 'processWithAutoHandling')); + } + + protected function tearDown(): void { + unset($_GET['service']); + unset($_SERVER['REQUEST_METHOD']); + } +} diff --git a/tests/WebFiori/Tests/Http/WebServicesManagerTest.php b/tests/WebFiori/Tests/Http/WebServicesManagerTest.php index 3a819ad..852fccb 100644 --- a/tests/WebFiori/Tests/Http/WebServicesManagerTest.php +++ b/tests/WebFiori/Tests/Http/WebServicesManagerTest.php @@ -1,7 +1,7 @@ addService(new NoAuthService()); @@ -144,7 +144,7 @@ public function testJson04() { public function testRemoveService00(WebServicesManager $manager) { $this->assertNull($manager->removeService('xyz')); $service = $manager->removeService('ok-service'); - $this->assertTrue($service instanceof AbstractWebService); + $this->assertTrue($service instanceof WebService); $this->assertEquals(0, count($manager->getServices())); $this->assertNull($service->getManager()); } @@ -160,9 +160,9 @@ public function testConstructor00() { $this->assertEquals('1.0.1',$api->getVersion()); $this->assertEquals('NO DESCRIPTION',$api->getDescription()); $api->setDescription('Test API.'); - $this->assertEquals(5,count($api->getServices())); + $this->assertEquals(6,count($api->getServices())); $this->assertEquals('Test API.',$api->getDescription()); - $this->assertTrue($api->getServiceByName('sum-array') instanceof AbstractWebService); + $this->assertTrue($api->getServiceByName('sum-array') instanceof WebService); $this->assertNull($api->getServiceByName('request-info')); $this->assertNull($api->getServiceByName('api-info-2')); @@ -300,6 +300,61 @@ public function testCreateUser01() { $manager->process(); $this->assertEquals('{"user":{"Id":3,"FullName":"Me","Username":"Cpool"}}', $manager->readOutputStream()); } + /** + * @test + */ + public function testCreateUser02() { + $this->clrearVars(); + putenv('REQUEST_METHOD=GET'); + $_GET['service'] = 'user-profile'; + $_GET['id'] = '99'; + $api = new SampleServicesManager(); + $api->setOutputStream($this->outputStreamName); + $api->process(); + $this->assertEquals('{"user":{"Id":99,"FullName":"Ibx"}}', $api->readOutputStream()); + } + /** + * @test + */ + public function testCreateUser03() { + $this->clrearVars(); + putenv('REQUEST_METHOD=POST'); + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $_POST['service'] = 'user-profile'; + $_POST['id'] = '99'; + $api = new SampleServicesManager(); + $api->setOutputStream($this->outputStreamName); + $api->process(); + $this->assertEquals('{"message":"The following required parameter(s) where missing from the request body: \'name\', \'username\'.","type":"error","http-code":404,"more-info":{"missing":["name","username"]}}', $api->readOutputStream()); + } + /** + * @test + */ + public function testCreateUser04() { + $this->clrearVars(); + putenv('REQUEST_METHOD=POST'); + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $_POST['service'] = 'user-profile'; + $_POST['name'] = '99'; + $_POST['username'] = 'Cool'; + $api = new SampleServicesManager(); + $api->setOutputStream($this->outputStreamName); + $api->process(); + $this->assertEquals('{"user":{"Id":3,"FullName":"99","Username":"Cool"}}', $api->readOutputStream()); + } + /** + * @test + */ + public function testCreateUser05() { + $this->clrearVars(); + putenv('REQUEST_METHOD=GET'); + $_GET['service'] = 'user-profile'; + + $api = new SampleServicesManager(); + $api->setOutputStream($this->outputStreamName); + $api->process(); + $this->assertEquals('{"message":"The following required parameter(s) where missing from the request body: \'id\'.","type":"error","http-code":404,"more-info":{"missing":["id"]}}', $api->readOutputStream()); + } /** * @test */ @@ -595,10 +650,10 @@ public function testSetOutputStream02() { */ public function testSetOutputStream03() { $api = new SampleServicesManager(); - $this->assertTrue($api->setOutputStream(__DIR__.DIRECTORY_SEPARATOR.'outputStream.txt', true)); + $this->assertTrue($api->setOutputStream(__DIR__.DIRECTORY_SEPARATOR.'output-stream.txt', true)); $this->assertNotNull($api->getOutputStream()); $this->assertNotNull($api->getOutputStreamPath()); - $this->assertEquals(__DIR__.DIRECTORY_SEPARATOR.'outputStream.txt', $api->getOutputStreamPath()); + $this->assertEquals(__DIR__.DIRECTORY_SEPARATOR.'output-stream.txt', $api->getOutputStreamPath()); } private function clrearVars() { foreach ($_GET as $k => $v) { diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 58b4d7e..dbfd68a 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -1,11 +1,10 @@ - + - - ../WebFiori/Http/AbstractWebService.php - ../WebFiori/Http/APIFilter.php + ../WebFiori/Http/AbstractWebService.php + ../WebFiori/Http/WebService.php../WebFiori/Http/APIFilter.php ../WebFiori/Http/RequestParameter.php ../WebFiori/Http/WebServicesManager.php ../WebFiori/Http/Request.php @@ -17,10 +16,9 @@ ../WebFiori/Http/UriParameter.php ../WebFiori/Http/ObjectMapper.php ../WebFiori/Http/AuthHeader.php - - + ../WebFiori/Http/OpenAPI/ComponentsObj.php../WebFiori/Http/OpenAPI/ContactObj.php../WebFiori/Http/OpenAPI/ExternalDocObj.php../WebFiori/Http/OpenAPI/HeaderObj.php../WebFiori/Http/OpenAPI/InfoObj.php../WebFiori/Http/OpenAPI/LicenseObj.php../WebFiori/Http/OpenAPI/MediaTypeObj.php../WebFiori/Http/OpenAPI/OAuthFlowObj.php../WebFiori/Http/OpenAPI/OAuthFlowsObj.php../WebFiori/Http/OpenAPI/OpenAPIObj.php../WebFiori/Http/OpenAPI/OperationObj.php../WebFiori/Http/OpenAPI/ParameterObj.php../WebFiori/Http/OpenAPI/PathItemObj.php../WebFiori/Http/OpenAPI/PathsObj.php../WebFiori/Http/OpenAPI/ReferenceObj.php../WebFiori/Http/OpenAPI/ResponseObj.php../WebFiori/Http/OpenAPI/ResponsesObj.php../WebFiori/Http/OpenAPI/Schema.php../WebFiori/Http/OpenAPI/SecurityRequirementObj.php../WebFiori/Http/OpenAPI/SecuritySchemeObj.php../WebFiori/Http/OpenAPI/ServerObj.php../WebFiori/Http/OpenAPI/TagObj.php../WebFiori/Http/ManagerInfoService.php../WebFiori/Http/ResponseMessage.php - + diff --git a/tests/phpunit10.xml b/tests/phpunit10.xml index c63e0e4..26e0c22 100644 --- a/tests/phpunit10.xml +++ b/tests/phpunit10.xml @@ -1,22 +1,21 @@ - + - + - + ./WebFiori/Tests/Http - - ../WebFiori/Http/AbstractWebService.php - ../WebFiori/Http/APIFilter.php + ../WebFiori/Http/AbstractWebService.php + ../WebFiori/Http/WebService.php../WebFiori/Http/APIFilter.php ../WebFiori/Http/RequestParameter.php ../WebFiori/Http/WebServicesManager.php ../WebFiori/Http/Request.php @@ -31,6 +30,5 @@ ../WebFiori/Http/HttpMessage.php ../WebFiori/Http/RequestV2.php ../WebFiori/Http/RequestUri.php - - - + ../WebFiori/Http/OpenAPI/ComponentsObj.php../WebFiori/Http/OpenAPI/ContactObj.php../WebFiori/Http/OpenAPI/ExternalDocObj.php../WebFiori/Http/OpenAPI/HeaderObj.php../WebFiori/Http/OpenAPI/InfoObj.php../WebFiori/Http/OpenAPI/LicenseObj.php../WebFiori/Http/OpenAPI/MediaTypeObj.php../WebFiori/Http/OpenAPI/OAuthFlowObj.php../WebFiori/Http/OpenAPI/OAuthFlowsObj.php../WebFiori/Http/OpenAPI/OpenAPIObj.php../WebFiori/Http/OpenAPI/OperationObj.php../WebFiori/Http/OpenAPI/ParameterObj.php../WebFiori/Http/OpenAPI/PathItemObj.php../WebFiori/Http/OpenAPI/PathsObj.php../WebFiori/Http/OpenAPI/ReferenceObj.php../WebFiori/Http/OpenAPI/ResponseObj.php../WebFiori/Http/OpenAPI/ResponsesObj.php../WebFiori/Http/OpenAPI/Schema.php../WebFiori/Http/OpenAPI/SecurityRequirementObj.php../WebFiori/Http/OpenAPI/SecuritySchemeObj.php../WebFiori/Http/OpenAPI/ServerObj.php../WebFiori/Http/OpenAPI/TagObj.php../WebFiori/Http/ManagerInfoService.php../WebFiori/Http/ResponseMessage.php + \ No newline at end of file