feat: add cors middleware helper, add all route handler

This commit is contained in:
Khairul Hidayat 2024-08-27 15:59:13 +07:00 committed by Jamie Barton
parent f1d733c37d
commit 11b85823fa
4 changed files with 207 additions and 104 deletions

View File

@ -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.

View File

@ -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",

69
src/Helpers/CORS.php Normal file
View File

@ -0,0 +1,69 @@
<?php
namespace Dumbo\Helpers;
use Dumbo\Context;
class CORS
{
/**
* Configure CORS headers.
*
* @param array $opts optional - The CORS options
* @return callable The middleware function for handling CORS.
*/
public static function cors(array $opts = []): callable
{
return function (Context $c, callable $next) use ($opts) {
$origin = $c->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);
};
}
}

View File

@ -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<array{method: string, path: string, handler: callable(Context): (ResponseInterface|null), middleware: array}> */
private $routes = [];
/**
* Array of route groups
*
* @var array<string, 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<callable> $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<string, string|null> 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;
}
}