diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index f9cf6fc..aa296f0 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -2,7 +2,7 @@ name: PHP Tests on: push: - branches: [ main,feature/v2 ] + branches: [ main ] pull_request: branches: [ main ] diff --git a/.gitignore b/.gitignore index 8860a6c..b794d65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .idea/ sample/logs vendor/ -*.lock \ No newline at end of file +*.lock +docs/ +.phpunit.result.cache \ No newline at end of file diff --git a/Exceptions/CallbackNotFound.php b/Exceptions/CallbackNotFound.php new file mode 100644 index 0000000..8744b9c --- /dev/null +++ b/Exceptions/CallbackNotFound.php @@ -0,0 +1,12 @@ +"; -} +## Install via composer -# include_once "../Routes.php"; IF NOT USING composer - -try { - $routes = new Routes(); +```shell +composer require gac/routing +``` - $routes->add('/', function () { - echo "Welcome"; - })->middleware([ - ["verify_token", "test"] - ]); +## Manual install - $routes->add('/test', function () { - echo "Welcome to test route"; - }); +Download the latest release from the [Releases page](https://github.com/gigili/PHP-routing/releases). - $routes->add('/test_middleware', function () { - echo "This will call middleware function without passing the parameter"; - })->middleware(["verify_token"]); +Don't forget to add these include statements to your php files: - $routes->route(); -} catch (RouteNotFoundException $ex) { - header("HTTP/1.1 404"); - echo "Route not found"; -} catch (Exception $ex) { - die("API Error: {$ex->getMessage()}"); -} +```php +include_once "./Exceptions/CallbackNotFound.php"; +include_once "./Exceptions/RouteNotFoundException.php"; +include_once "./Request.php"; +include_once "./Routes.php"; ``` +## After install -To use this class properly you will need to create a `.htaccess` file at the root of the project. +To use this library properly you will need to create a `.htaccess` file at the root of the project. Example of the `.htaccess` file would look like this: -``` +```apacheconf RewriteEngine On RewriteBase / RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d -RewriteRule ^(.+)$ index.php?myUri=$1 [QSA,L] +RewriteRule ^(.+)$ index.php [QSA,L] ``` -Do **NOT** change the `?myUri=$1` part in the `.htaccess` file as that will prevent the class from working. +### Note + +If you've named your main file differently, replace `index.php` in the `.htaccess` file with that ever your main application file is. + +## Quick start +Sample code to allow you to quickly start with your development. + +```php +use Gac\Routing\Exceptions\CallbackNotFound; +use Gac\Routing\Exceptions\RouteNotFoundException; +use Gac\Routing\Request; +use Gac\Routing\Routes; -## Note ## -When using middleware make sure the middleware function has benn declared before the Routes class import. +include_once "vendor/autoload.php"; # IF YOU'RE USING composer + +$routes = new Routes(); +try { + $routes->add('/', function (Request $request) { + $request + ->status(200, "OK") + ->send(["message" => "Welcome"]); + }); + + $routes->route(); +} catch (RouteNotFoundException $ex) { + $routes->request->status(404, "Route not found")->send(["error" => ["message" => $ex->getMessage()]]); +} catch (CallbackNotFound $ex) { + $routes->request->status(404, "Callback method not found")->send(["error" => ["message" => $ex->getMessage()]]); +} catch (Exception $ex) { + $code = $ex->getCode() ?? 500; + $routes->request->status($code)->send(["error" => ["message" => $ex->getMessage()]]); +} +``` -# Features +## Features * [x] Static routes * [x] Dynamic routes -* [x] Middleware -* [] Prefixing routes +* [x] Middlewares +* [x] Prefixing routes diff --git a/Request.php b/Request.php new file mode 100644 index 0000000..28e629e --- /dev/null +++ b/Request.php @@ -0,0 +1,67 @@ +data = $_REQUEST; + } + + /** + * Returns a value for a specified body argument + * + * @param string $key Which request body argument to be returned + * + * @return mixed|null Body argument value or NULL if the argument doesn't exist + */ + public function get(string $key = ""): mixed { + return $this->data[$key] ?? NULL; + } + + /** + * Returns list of all the header items or a value of a specific item + * + * @param string $key Name of a specific item in the header list to return the value for + * + * @return array|string|null List of header values or a value of a single item + */ + public function headers(string $key = ""): array|string|null { + $headers = getallheaders(); + return empty($key) ? $headers : $headers[$key] ?? NULL; + } + + /** + * Sets the header status code for the response + * + * @param int $statusCode Status code to be set for the response + * @param string $message Message to be returned in the header alongside the status code + * + * @return Request Returns an instance of the Request class so that it can be chained on + */ + public function status(int $statusCode = 200, string $message = ""): self { + header("HTTP/1.1 {$statusCode} {$message}"); + return $this; + } + + /** + * Send response back + * + * @param string|array|object $output Value to be outputted as part of the response + */ + public function send(string|array|object $output) { + echo json_encode($output); + } + } \ No newline at end of file diff --git a/Routes.php b/Routes.php index dede7f7..4641cfe 100644 --- a/Routes.php +++ b/Routes.php @@ -1,190 +1,249 @@ + * @copyright 2020-2021 Igor Ilić + * @license GNU General Public License v3.0 + * + */ + + declare(strict_types=1); namespace Gac\Routing; - use Exception; + use Gac\Routing\Exceptions\CallbackNotFound; use Gac\Routing\Exceptions\RouteNotFoundException; + use JetBrains\PhpStorm\Pure; + - /** - * Custom routing utility - */ class Routes { + /** + * @var string GET Constant representing a GET request method + */ + public const GET = "GET"; + + /** + * @var string POST Constant representing a POST request method + */ + public const POST = "POST"; + + /** + * @var string PUT Constant representing a PUT request method + */ + public const PUT = "PUT"; + + /** + * @var string PATCH Constant representing a PATCH request method + */ + public const PATCH = "PATCH"; + + /** + * @var string DELETE Constant representing a DELETE request method + */ + public const DELETE = "DELETE"; + + /** + * @var Request $request Instance of a Request class to be passed as an argument to routes callback + */ + public Request $request; + /** * @var array $routes List of available routs */ - private array $routes; + private array $routes = []; + + /** + * Return the list of defined routed + * + * @return array + */ + public function getRoutes(): array { + return $this->routes; + } + + /** + * @var string $prefix Prefix to be added to routes being created + */ + private string $prefix = ""; + + /** + * @var array $middlewares List of middlewares to be executed before accessing a route + */ + private array $middlewares = []; /** - * Constructor function used to initialize the Routes utility + * Routes constructor */ public function __construct() { - $this->routes = []; + $this->request = new Request; } /** * Method used for adding new routes * - * @param string $url URL of the rout - * @param string|null|callable $callback Callback method or an anonymous function to be executed - * @param array $method Allowed request methods (GET, POST, PUT...) + * @param string $path Path for the route + * @param callable|array|string $callback Callback method, an anonymous function or a class and method name to be executed + * @param string|array $methods Allowed request method(s) (GET, POST, PUT...) * - * @return Routes returns the instance of the Routes utility - * @throws Exception Throws an exception when you try to declare and already existing route + * @return Routes returns an instance of it self so that the next method can be chained onto it */ - public function add(string $url = "", callable|string|null $callback = NULL, array $method = ["GET"]): self { - $tmpUrl = $url; + public function add(string $path, callable|array|string $callback, string|array $methods = self::GET): self { + if (is_string($methods)) $methods = [$methods]; - foreach ($method as $m) { - $url = $tmpUrl; - $url = trim($url, "/"); - $url = preg_replace("/\s/", "-", $url); - $url .= "-$m"; - $url = ltrim($url, "-"); + if (!empty($this->prefix)) $path = "{$this->prefix}{$path}"; // Prepend prefix to routes - if (isset($this->routes[$url]) && $this->routes[$url]['allowed_method'] == $method) { - throw new Exception("The specified path: ( $tmpUrl | $method ) already exists!", 50001); - } + if ($path !== "/") $path = rtrim($path, "/"); - $nUrl = NULL; - if (str_contains($url, ":")) { - $nUrl = preg_replace("/(:[\w\-_]+)/", "([\w\-\_\:]+)", $url); - $nUrl = str_replace("/", "\/", $nUrl); - if (!str_contains($nUrl, "-{$m}")) { - $nUrl .= "-{$m}"; - } - $nUrl .= "$"; - } + $regex = NULL; + $arguments = NULL; + if (str_contains($path, "{")) { + $regex = preg_replace("/{.+?}/", "(.+?)", $path); + $regex = str_replace("/", "\/", $regex); + $regex = "^{$regex}$"; + preg_match_all("/{(.+?)}/", $path, $matches); + if (isset($matches[1]) && count($matches) > 0) $arguments = $matches[1]; + } - $this->routes[$url] = [ - "url" => $url, + foreach ($methods as $method) { + $this->routes[$method][$path] = [ "callback" => $callback, - "allowed_method" => $m, - "params" => [], - "regex" => $nUrl, - "middleware" => [] + "middlewares" => $this->middlewares ]; + + if (!is_null($regex)) { + $this->routes[$method][$path]["regex"] = $regex; + if (!is_null($arguments)) { + $this->routes[$method][$path]["arguments"] = $arguments; + } + } } + $this->middlewares = []; + $this->prefix = ""; + return $this; } /** - * Method which handles all the routing and mapping of dynamic routes + * Method used to handle execution of routes and middlewares * - * @return Boolean Returns true if the route was found and called or false with a 404 status code on error - * @throws RouteNotFoundException|Exception Throws an exception if the middleware function can't be found - */ - public function route(): bool { - $url = isset($_GET['myUri']) ? $_GET['myUri'] : ""; - $url = rtrim($url, "/"); - $url .= "-{$_SERVER['REQUEST_METHOD']}"; - $url = ltrim($url, "-"); - - if (isset($this->routes[$url]) && $this->routes[$url]["allowed_method"] == $_SERVER["REQUEST_METHOD"]) { - if (count($this->routes[$url]["middleware"]) > 0) { - $this->execute_middleware($this->routes[$url]["middleware"]); + * @throws RouteNotFoundException|CallbackNotFound + */ + public function route() { + $path = $this->getPath(); + $method = $_SERVER["REQUEST_METHOD"] ?? "GET"; + + $route = $this->routes[$method][$path] ?? false; + + $arguments = []; + if ($route === false) { + $dynamic_routes = array_filter($this->routes[$method], fn($route) => !is_null($route["regex"] ?? NULL)); + foreach ($dynamic_routes as $route_path => $dynamic_route) { + if (preg_match("/{$dynamic_route["regex"]}/", $path)) { + $route = $dynamic_route; + preg_match_all("/{$dynamic_route["regex"]}/", $path, $matches); + if (count($matches) > 1) array_shift($matches); + $matches = array_map(fn($m) => $m[0], $matches); + + $args = $route["arguments"] ?? []; + foreach ($args as $index => $argumentName) { + $type = "string"; + if (str_contains($argumentName, ":")) { + $colonIndex = strpos($argumentName, ":"); + $type = substr($argumentName, 0, $colonIndex); + $argumentName = substr($argumentName, $colonIndex + 1, strlen($argumentName)); + } + + $value = $matches[$index] ?? NULL; + $value = match ($type) { + "int" => intval($value), + "float" => floatval($value), + "double" => doubleval($value), + "bool" => is_numeric($value) ? boolval($value) : ($value === "true"), + default => (string)$value, + }; + + $arguments[$argumentName] = $value; + } + break; + } } - - $this->routes[$url]["callback"]($this->routes[$url]["params"]); - return true; } - foreach ($this->routes as $route) { - if (!is_null($route["regex"])) { - if (preg_match("/^{$route["regex"]}/", $url) === 1) { - $urlIndex = $route["url"]; + if ($route === false) throw new RouteNotFoundException("Route {$path} not found", 404); - preg_match_all("/^{$route["regex"]}/", $url, $tmpParams); - preg_match_all("/^{$route["regex"]}/", $route["url"], $paramNames); - array_shift($tmpParams); - array_shift($paramNames); + $middlewares = $route["middlewares"] ?? []; + $this->execute_middleware($middlewares); - dd($this->routes); + $callback = $route["callback"] ?? false; + if ($callback === false) throw new CallbackNotFound("No callback specified for {$path}", 404); - $params = []; - for ($x = 0; $x < count($paramNames); $x++) { - $params[str_replace(":", "", $paramNames[$x][0] ?? "")] = $tmpParams[$x][0] ?? ""; - } - - if (is_array($this->routes[$urlIndex]["params"])) { - $params = array_merge($params, $this->routes[$urlIndex]["params"]); - } + if ((is_string($callback) && class_exists($callback)) || is_array($callback)) { + $controller = is_string($callback) ? new $callback : new $callback[0]; // make a new instance of a controller class + $fn = is_string($callback) ? "index" : $callback[1] ?? "index"; // get the method to be execute or fallback to index method + $callback = [$controller, $fn]; + } - if (count($this->routes[$urlIndex]["middleware"]) > 0) { - $this->execute_middleware($this->routes[$urlIndex]["middleware"]); - } + if (!is_callable($callback)) throw new CallbackNotFound("Unable to execute callback for {$path}", 404); + call_user_func($callback, $this->request, ...$arguments); + } - $this->routes[$urlIndex]["callback"]($params); - return true; - } - } - } - throw new RouteNotFoundException(); + /** + * Method which adds a prefix to route or a group of routes + * + * @param string $prefix Prefix to be added + * + * @return Routes returns an instance of it self so that the next method can be chained onto it + */ + public function prefix(string $prefix = ""): self { + $this->prefix = $prefix; + return $this; } /** * Method used to set the middleware to be run before accessing API endpoint * - * @param array $data List of methods to be executed before accessing the endpoint + * @param array $data List of middlewares to be executed before accessing the endpoint * - * @return Routes returns an instance of it self so that the next method can be chained onto it. - * @throws Exception If the specified method is not found + * @return Routes returns an instance of it self so that the next method can be chained onto it */ public function middleware(array $data): self { - $routeKeys = array_keys($this->routes); - $tmpRoute = $this->routes[$routeKeys[count($routeKeys) - 1]]["url"]; - - foreach ($data as $function) { - $param = NULL; - - if (!is_string($function)) { - $param = $function[1]; - $function = $function[0]; - } - - if (function_exists($function)) { - if (!is_null($param)) { - array_push($this->routes[$tmpRoute]["middleware"], [$function, $param]); - } else { - array_push($this->routes[$tmpRoute]["middleware"], $function); - } - } else { - throw new Exception("Function $function doesn't exists"); - } - } - + $this->middlewares = $data; return $this; } /** - * Method which executes each specified middleware before the endpoint is called + * Method which executes each specified middleware before the routes callback is executed * - * @param array $data List of methods to be executed before accessing the endpoint + * @param array $data List of middlewares to be executed before accessing the endpoint * - * @return Routes returns an instance of it self so that the next method can be chained onto it. - * @throws Exception If the specified method is not found + * @throws CallbackNotFound When the specified middleware method is not found */ - private function execute_middleware(array $data): self { + private function execute_middleware(array $data) { foreach ($data as $function) { - $param = NULL; - if (!is_string($function)) { - $param = $function[1]; - $function = $function[0]; + if (is_array($function)) { + $function = [new $function[0], $function[1]]; } - if (function_exists($function)) { - if (!is_null($param)) { - $function(is_array($param) && count($param) === 1 ? $param[0] : $param); - } else { - $function(); - } - } else { - throw new Exception("Function $function doesn't exists"); - } + if (!is_callable($function)) throw new CallbackNotFound("Middleware method {$function} not found", 404); + + call_user_func($function, $this->request); } + } - return $this; + /** + * Method which returns the current path the user is trying to access + * + * @return string Returns the current path + */ + #[Pure] private function getPath(): string { + $path = $_SERVER["REQUEST_URI"] ?? "/"; + $position = strpos($path, "?"); + + $path = ($path !== "/") ? rtrim($path, "/") : $path; + return ($position === false) ? $path : substr($path, 0, $position); } - } + } \ No newline at end of file diff --git a/composer.json b/composer.json index 9753fe6..a4fd952 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,15 @@ { "name": "gac/routing", - "type": "class", - "description": "Custom routing class", + "type": "library", + "description": "Custom routing library especially useful for API development", "homepage": "https://github.com/gigili/PHP-routing", "license": "GPL-3.0-only", "keywords": [ "route", - "routing" + "routing", + "libray", + "middleware", + "api" ], "authors": [ { @@ -15,6 +18,20 @@ "role": "Developer" } ], + "support": { + "email": "github@igorilic.net", + "issues": "https://github.com/gigili/PHP-routing" + }, + "funding": [ + { + "type": "paypal", + "url": "https://paypal.me/igorili" + }, + { + "type": "ko-fi", + "url": "https://ko-fi.com/igorilic" + } + ], "require": { "php": ">=8.0", "ext-json": "*" @@ -24,5 +41,8 @@ "Gac\\Routing\\": "./", "Gac\\Routing\\Exceptions\\": "./" } + }, + "require-dev": { + "phpunit/phpunit": "^9.5" } -} \ No newline at end of file +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..2324beb --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + + + + + + ./tests/ + + + + diff --git a/sample/.htaccess b/sample/.htaccess index 5bc3cd3..94e99ee 100644 --- a/sample/.htaccess +++ b/sample/.htaccess @@ -4,4 +4,4 @@ RewriteBase / RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d -RewriteRule ^(.+)$ index.php?myUri=$1 [QSA,L] \ No newline at end of file +RewriteRule ^(.+)$ index.php [QSA,L] \ No newline at end of file diff --git a/sample/HomeController.php b/sample/HomeController.php new file mode 100644 index 0000000..637aa82 --- /dev/null +++ b/sample/HomeController.php @@ -0,0 +1,40 @@ +send(["message" => "Hello from controller::home"]); + } + + public function getUsers(Request $request) { + $request->send(["message" => "Hello from controller::home", "ses" => $_SESSION]); + } + + public function addUser(Request $request) { + $request->send(["message" => "Hello from controller::home"]); + } + + public function updateUser(Request $request) { + $request->send(["message" => "Hello from controller::home"]); + } + + public function replaceUser(Request $request) { + $request->send(["message" => "Hello from controller::home"]); + } + + public function deleteUser(Request $request) { + $request->send(["message" => "Hello from controller::home"]); + } + + public function test(Request $request, int $userID, string $username, float $amount, bool $valid) { + echo "Dynamic route here"; + } + } \ No newline at end of file diff --git a/sample/Middleware.php b/sample/Middleware.php new file mode 100644 index 0000000..e973ed9 --- /dev/null +++ b/sample/Middleware.php @@ -0,0 +1,19 @@ +"; - } + #include_once "../Routes.php"; # IF YOU'RE NOT USING composer + #include_once "HomeController.php"; # IF YOU'RE NOT USING composer - # include_once "../Routes.php"; IF NOT USING composer + include_once "../vendor/autoload.php"; # IF YOU'RE USING composer + $routes = new Routes(); try { - $routes = new Routes(); + $routes->add('/', function (Request $request) { + $request + ->status(200, "OK") + ->send(["message" => "Welcome"]); + }); + + $routes->add('/test', "test_route_function", [Routes::GET, Routes::POST]); - $routes->add('/', function () { - echo "Welcome"; - })->middleware([ - ["verify_token", "test"] - ]); + $routes->prefix("/user") + ->middleware(["verify_token"]) + ->add('/', [HomeController::class, "getUsers"], Routes::GET) + ->add("/", [HomeController::class, "addUser"], Routes::POST) + ->add("/", [HomeController::class, "updateUser"], Routes::PATCH) + ->add("/", [HomeController::class, "replaceUser"], Routes::PUT) + ->add("/", [HomeController::class, "deleteUser"], Routes::DELETE); - $routes->add('/test', function () { - echo "Welcome to test route"; + $routes->add("/test/{int:userID}-{username}/{float:amount}/{bool:valid}", function ( + Request $request, + int $userID, + string $username, + float $amount, + bool $valid + ) { + echo "Dynamic route here"; }); - $routes->add('/test_middleware', function () { - echo "This will call middleware function without passing the parameter"; - })->middleware(["verify_token"]); + // $routes->add("/test/{int:userID}-{username}/{float:amount}/{bool:valid}", [HomeController::class, "test"]); # It works like this also + + $routes + ->middleware([ + [Middleware::class, "verify_token"], + [Middleware::class, "test"], + "verify_token" + ]) + ->add("/test", function (Request $request) { + $request->send(["message" => "Hello"]); + }); + $routes->route(); } catch (RouteNotFoundException $ex) { - header("HTTP/1.1 404"); - echo "Route not found"; + $routes->request->status(404, "Route not found")->send(["error" => ["message" => $ex->getMessage()]]); + } catch (CallbackNotFound $ex) { + $routes->request->status(404, "Callback method not found")->send(["error" => ["message" => $ex->getMessage()]]); } catch (Exception $ex) { - die("API Error: {$ex->getMessage()}"); + $code = $ex->getCode() ?? 500; + $routes->request->status($code)->send(["error" => ["message" => $ex->getMessage()]]); + } + + function test_route_function() { + echo json_encode(["message" => "Welcome from test route"]); + } + + function verify_token() { + //Do something } \ No newline at end of file diff --git a/tests/RoutesTest.php b/tests/RoutesTest.php new file mode 100644 index 0000000..147f7b1 --- /dev/null +++ b/tests/RoutesTest.php @@ -0,0 +1,37 @@ +routes = new Routes(); + } + + public function testCanAddNewRoute() { + $this->routes->add("/", []); + $this->assertTrue(isset($this->routes->getRoutes()["GET"]["/"]), "Unable to add new route"); + } + + public function testCanAddMultipleRequestMethodRoutes() { + $this->routes->add("/test", [], [Routes::GET, Routes::POST]); + $routes = $this->routes->getRoutes(); + $this->assertTrue(isset($routes["GET"]["/test"]) && isset($routes["POST"]["/test"]), "Unable to add new route"); + } + + public function testCannAddMiddleware() { + $this->routes->middleware(["test"])->add("/middleware", []); + $this->assertTrue($this->routes->getRoutes()["GET"]["/middleware"]["middlewares"][0] == "test", "Unable to add middleware"); + } + + public function testCannAddPrefix() { + $this->routes->prefix("/testing")->add("/test", []); + $routes = $this->routes->getRoutes(); + + $this->assertTrue(isset($routes["GET"]["/testing/test"]), "Unable to add prefix to routes"); + } + }