From 11b85823fa85f285853563fc99e747b2cdb0d8c6 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Tue, 27 Aug 2024 15:59:13 +0700 Subject: [PATCH] feat: add cors middleware helper, add `all` route handler --- README.md | 46 ++++++++++ src/Dumbo.php | 2 + src/Helpers/CORS.php | 69 +++++++++++++++ src/Router.php | 194 ++++++++++++++++++++----------------------- 4 files changed, 207 insertions(+), 104 deletions(-) create mode 100644 src/Helpers/CORS.php diff --git a/README.md b/README.md index 56e7abc..ff078ad 100644 --- a/README.md +++ b/README.md @@ -133,3 +133,49 @@ $app->get('/api/data', function(Context $context) { $app->run(); ``` + +### CORS + +Usage: + +```php +use Dumbo\Helpers\CORS; + +$app->use(CORS::cors()); // allow from all origin +// or +$app->use(CORS::cors([ + 'origin' => fn($origin, $c) => $origin, + 'allow_headers' => ['X-Custom-Header', 'Upgrade-Insecure-Requests'], + 'allow_methods' => ['POST', 'GET', 'OPTIONS'], + 'expose_headers' => ['Content-Length', 'X-Kuma-Revision'], + 'max_age' => 600, + 'credentials' => true, +])); + +``` + +Options: + +- **origin**: `string` | `string[]` | `callable(string, Context): string` + + The value of "_Access-Control-Allow-Origin_" CORS header. You can also pass the callback function like `'origin': fn($origin, $c) => (str_ends_with($origin, '.example.com') ? $origin : 'http://example.com')`. The default is `*`. + +- **allow_headers**: `string[]` + + The value of "_Access-Control-Allow-Headers_" CORS header. The default is `[]`. + +- **allow_methods**: `string[]` + + The value of "_Access-Control-Allow-Methods_" CORS header. The default is `['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH']`. + +- **expose_headers**: `string[]` + + The value of "_Access-Control-Expose-Headers_" CORS header. + +- **credentials**: `bool` + + The value of "_Access-Control-Allow-Credentials_" CORS header. + +- **max_age**: `int` + + The value of "_Access-Control-Max-Age_" CORS header. diff --git a/src/Dumbo.php b/src/Dumbo.php index f9b01b5..f188e47 100644 --- a/src/Dumbo.php +++ b/src/Dumbo.php @@ -14,6 +14,7 @@ use GuzzleHttp\Psr7\ServerRequest; * @author Jamie Barton * @version 1.0.0 * + * @method void all(string $path, callable(Context): (ResponseInterface|null) $handler) Add a catch-all method route * @method void get(string $path, callable(Context): (ResponseInterface|null) $handler) Add a GET route * @method void post(string $path, callable(Context): (ResponseInterface|null) $handler) Add a POST route * @method void put(string $path, callable(Context): (ResponseInterface|null) $handler) Add a PUT route @@ -53,6 +54,7 @@ class Dumbo public function __call(string $method, array $arguments): void { $supportedMethods = [ + "all", "get", "post", "put", diff --git a/src/Helpers/CORS.php b/src/Helpers/CORS.php new file mode 100644 index 0000000..f575276 --- /dev/null +++ b/src/Helpers/CORS.php @@ -0,0 +1,69 @@ +req->header('origin'); + + $allowOrigin = $opts['origin'] ?? '*'; + $allowMethods = $opts['allow_methods'] ?? ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH']; + + if (is_callable($allowOrigin)) { + $allowOrigin = $allowOrigin($origin, $c); + } else if (is_array($allowOrigin) && array_is_list($allowOrigin)) { + $allowOrigin = in_array($origin, $allowOrigin) ? $origin : $allowOrigin[0]; + } + + if (!empty($allowOrigin)) { + $c->header('Access-Control-Allow-Origin', $allowOrigin); + } + + if ($opts['credentials']) { + $c->header('Access-Control-Allow-Credentials', 'true'); + } + if (!empty($opts['expose_headers'])) { + $c->header('Access-Control-Expose-Headers', implode(', ', $opts['expose_headers'])); + } + + if ($c->req->method() === 'OPTIONS') { + if (!empty($opts['max_age'])) { + $c->header('Access-Control-Max-Age', (string) $opts['max_age']); + } + + if (!empty($allowMethods)) { + $c->header('Access-Control-Allow-Methods', implode(', ', $allowMethods)); + } + + $headers = $opts['allow_headers']; + if (empty($headers)) { + $reqHeaders = $c->req->header('Access-Control-Request-Headers'); + if (!empty($reqHeaders)) { + $headers = array_map('trim', explode(',', $reqHeaders)); + } + } + if (!empty($headers)) { + $c->header('Access-Control-Allow-Headers', implode(', ', $opts['allow_headers'])); + } + + return $c->getResponse() + ->withStatus(204) + ->withoutHeader('Content-Length') + ->withoutHeader('Content-Type'); + } + + return $next($c); + }; + } +} diff --git a/src/Router.php b/src/Router.php index b9b9f43..3e022bd 100644 --- a/src/Router.php +++ b/src/Router.php @@ -2,45 +2,23 @@ namespace Dumbo; -use FastRoute\Dispatcher; -use FastRoute\RouteCollector; -use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -/** - * Router class for handling HTTP routes in the Dumbo framework - */ class Router { - private ?Dispatcher $dispatcher = null; - /** @var array */ private $routes = []; - /** - * Array of route groups - * - * @var array - */ - private array $groups = []; - - /** - * Constructor - * - * Initializes the router and invokes the initial dispatcher. - */ - public function __construct() - { - $this->rebuildDispatcher(); - } + /** @var string */ + private $prefix = ""; /** * Add a route to the router * - * @param string $method The HTTP method for the route - * @param string $path The URL path for the route - * @param callable $handler The handler function for the route - * @param array $middleware Array of middleware functions for the route + * @param string $method The HTTP method for this route + * @param string $path The path for this route + * @param callable(Context): (ResponseInterface|null) $handler The handler function for this route + * @param array $middleware Array of middleware functions for this route */ public function addRoute( string $method, @@ -50,110 +28,118 @@ class Router ): void { $this->routes[] = [ "method" => $method, - "path" => $path, + "path" => $this->prefix . $path, "handler" => $handler, "middleware" => $middleware, ]; - $this->rebuildDispatcher(); } - /** - * Add a group of routes with a common prefix - * - * @param string $prefix The common prefix for the group of routes - * @param array $groupRoutes Array of routes in the group - */ - public function addGroup(string $prefix, array $groupRoutes): void - { - $this->groups[$prefix] = $groupRoutes; - $this->rebuildDispatcher(); - } - - /** - * Find a matching route for the given request - * - * @param ServerRequestInterface $request The incoming HTTP request - * @return array|null The matched route information or null if no match found - */ public function findRoute(ServerRequestInterface $request): ?array { - if (!$this->dispatcher) { - return null; - } + $method = $request->getMethod(); + $path = $request->getUri()->getPath(); - $httpMethod = $request->getMethod(); - $uri = $this->normalizeUri($request->getUri()->getPath()); - - $routeInfo = $this->dispatcher->dispatch($httpMethod, $uri); - - if ($routeInfo[0] === Dispatcher::FOUND) { - $handler = $routeInfo[1]; - $vars = $routeInfo[2]; - - return [ - "handler" => $handler["handler"], - "params" => $vars, - "routePath" => $handler["path"], - "middleware" => $handler["middleware"] ?? [], - ]; + foreach ($this->routes as $route) { + if ( + ($route["method"] === $method || + $route["method"] === "ALL" || + $method === "OPTIONS") && + $this->matchPath($route["path"], $path) + ) { + $params = $this->extractParams($route["path"], $path); + return [ + "handler" => $route["handler"], + "params" => $params, + "routePath" => $route["path"], + ]; + } } return null; } /** - * Get all declared routes + * Check if a route path matches the request path * - * @return array All registered routes including group routes + * This method compares the route path pattern with the actual request path, + * accounting for path parameters (e.g., ":id" in "/users/:id"). + * + * @param string $routePath The route path pattern + * @param string $requestPath The actual request path + * @return bool True if the paths match, false otherwise */ - public function getRoutes(): array + private function matchPath(string $routePath, string $requestPath): bool { - $allRoutes = $this->routes; + $routePath = trim($routePath, "/"); + $requestPath = trim($requestPath, "/"); - foreach ($this->groups as $groupRoutes) { - $allRoutes = array_merge($allRoutes, $groupRoutes); + if ($routePath === "" && $requestPath === "") { + return true; } - return $allRoutes; - } + $routeParts = $routePath ? explode("/", $routePath) : []; + $requestParts = $requestPath ? explode("/", $requestPath) : []; - /** - * Prepare the path by converting :parameter syntax to {parameter} - * - * @param string $path The route path to prepare - * @return string The prepared path - */ - private function preparePath(string $path): string - { - $path = preg_replace("/:(\w+)/", '{$1}', $path); - return $this->normalizeUri($path); - } + if (count($routeParts) !== count($requestParts)) { + return false; + } - /** - * Rebuild the FastRoute dispatcher - * - * This method is called whenever routes are added or modified. - */ - private function rebuildDispatcher(): void - { - $this->dispatcher = \FastRoute\simpleDispatcher(function ( - RouteCollector $r - ) { - foreach ($this->routes as $route) { - $path = $this->preparePath($route["path"]); - $r->addRoute($route["method"], $path, $route); + foreach ($routeParts as $index => $routePart) { + if ($routePart[0] === ":") { + continue; } - }); + + if ($routePart !== $requestParts[$index]) { + return false; + } + } + + return true; } /** - * Normalize the given URI by ensuring it starts with a forward slash + * Extract parameters from the request path based on the route path * - * @param string $uri The URI to normalize - * @return string The normalized URI + * This method extracts values for path parameters defined in the route path + * (e.g., it will extract "123" as the value for ":id" from "/users/123" if the + * route path is "/users/:id"). + * + * @param string $routePath The route path pattern + * @param string $requestPath The actual request path + * @return array An associative array of parameter names and their values */ - private function normalizeUri(string $uri): string + private function extractParams( + string $routePath, + string $requestPath + ): array { + $params = []; + + $routePath = trim($routePath, "/"); + $requestPath = trim($requestPath, "/"); + + if ($routePath === "" && $requestPath === "") { + return $params; + } + + $routeParts = $routePath ? explode("/", $routePath) : []; + $requestParts = $requestPath ? explode("/", $requestPath) : []; + + foreach ($routeParts as $index => $routePart) { + if ($routePart[0] === ":") { + $params[substr($routePart, 1)] = $requestParts[$index] ?? null; + } + } + + return $params; + } + + public function setPrefix(string $prefix): void { - return "/" . trim($uri, "/"); + $this->prefix = $prefix; + } + + public function getRoutes(): array + { + return $this->routes; } }