From 05c225dbe528559c0cc448f035dedc9cfd526fe7 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Tue, 9 Sep 2025 21:49:16 -0300 Subject: [PATCH 01/64] feat: update level-2/dice --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 4fdbbf4..c315df9 100644 --- a/composer.json +++ b/composer.json @@ -10,8 +10,8 @@ } ], "require": { - "php": ">=5.4.0", - "level-2/dice": "^2.0" + "php": ">=7.4.0", + "level-2/dice": "^4.0" }, "require-dev": { "phpunit/phpunit": "4.*", From 7382255d87f79106f97bb594a0fc3617dbaf2150 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 24 May 2025 11:18:25 -0300 Subject: [PATCH 02/64] fix: remove uninitialized vars warnings --- src/Router.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Router.php b/src/Router.php index 2e5da91..7358878 100644 --- a/src/Router.php +++ b/src/Router.php @@ -57,7 +57,7 @@ public function route($request, $callback) ); if (empty($_SERVER['PATH_INFO']) && preg_match($p, $_SERVER['REDIRECT_URL'], $m)) { - $_SERVER['PATH_INFO'] = $m[2][0] != '/' ? '/' . $m[2] : $m[2]; + $_SERVER['PATH_INFO'] = (empty($m[2]) || $m[2][0] != '/') ? '/' . $m[2] : $m[2]; } @@ -230,7 +230,7 @@ public function controller($path, $controller) $response = call_user_func_array(array($controller, $fn), $params); // if client wants json, return right away - response will be encoded by route() and respond() - if (strpos($_SERVER['HTTP_ACCEPT'], 'json')) { + if (!empty($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'json')) { return $response; } @@ -455,7 +455,7 @@ public static function parse_post_body($decoded = true, $as_array = true) /** * Emits a `Location` header pointing to $to - * @param $to URL to redirect to + * @param $to string The URL to redirect to * @param int $code 3xx redirect code, from the HTTP spec, 301 is the default * 300 Multiple Choices * 301 Moved Permanently - This and all future requests should be directed to the given URI. @@ -511,7 +511,6 @@ public static function respond($content, $code = 200) public static function render($_template, $_data = []) { - if (!is_readable($_template)) { throw new \Exception("Cannot read $_template", 404); } From b3a8b7917a6f688c623d0d2e842327acf23452c4 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 28 Sep 2025 22:37:54 -0300 Subject: [PATCH 03/64] feat: Update to level-2/dice v4 --- src/Router.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Router.php b/src/Router.php index 7358878..1e4368b 100644 --- a/src/Router.php +++ b/src/Router.php @@ -4,12 +4,18 @@ use JMS\Serializer\SerializationContext; -class Router extends \Dice\Dice +class Router { private static $serializers = []; private $cors = null; + private \Dice\Dice $dice; + + function __construct() + { + $this->dice = new \Dice\Dice(); + } function setCors($cors) { @@ -26,6 +32,16 @@ static function hasSerializer($type) return !empty(self::$serializers[$type]); } + public function addRule($name, array $rule) + { + $this->dice = $this->dice->addRule($name, $rule); + } + + public function create(string $name, array $args = [], array $share = []) + { + return $this->dice->create($name, $args, $share); + } + /** * Route a particular request to a callback * From 5593b3e1cbbed2862a8b2c7aa8b9509e2b1ba987 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 28 Sep 2025 22:39:15 -0300 Subject: [PATCH 04/64] feat: Add Middleware support --- src/Router.php | 63 ++++++++++++++++++++++++++++++++------- src/Router/Middleware.php | 13 ++++++++ 2 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 src/Router/Middleware.php diff --git a/src/Router.php b/src/Router.php index 1e4368b..f5aec36 100644 --- a/src/Router.php +++ b/src/Router.php @@ -3,6 +3,7 @@ namespace Objectiveweb; use JMS\Serializer\SerializationContext; +use Objectiveweb\Router\Middleware; class Router { @@ -198,13 +199,15 @@ public function controller($path, $controller) header("Access-Control-Expose-Headers: content-range"); } + $refClass = new \ReflectionClass($controller); + $refMethod = new \ReflectionMethod($controller, $fn); + switch ($method) { // append the decoded body to the argument list for (post|put|patch).* methods case "post": case "put": case "patch": - $r = new \ReflectionMethod($controller, $fn); - $rparams = $r->getParameters(); + $rparams = $refMethod->getParameters(); $fn_param = array_pop($rparams); // auto deserialize when type hinted as class and jms/serializer is available if ($fn_param && $fn_param->getClass() && class_exists('\JMS\Serializer\SerializerBuilder')) { @@ -229,21 +232,59 @@ public function controller($path, $controller) break; } - // Process controller.before - $p = $this->_call([$controller, 'before'], $method, $fn, $params); + // Check middlewares + $middlewares = []; + + // Class Middlewares + foreach ($refClass->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF) as $attr) { + $middleware = $attr->newInstance(); + $middlewares[get_class($middleware)] = $middleware; + } - if ($p) { - $params = $p; + // Method Middlewares + foreach ($refMethod->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF) as $attr) { + $middleware = $attr->newInstance(); + unset($middlewares[get_class($middleware)]); // ensure method middleware is inserted after class middlewares + $middlewares[get_class($middleware)] = $middleware; } - // Process controller.before[Post|Get|Put|Delete|...] - $p = $this->_call([$controller, 'before' . ucfirst($method)], $fn, $params); - if ($p) { - $params = $p; + foreach ($middlewares as $mw) { + // Auto-inject dependencies from the controller + $attrRef = new \ReflectionObject($mw); + foreach ($attrRef->getProperties() as $prop) { + $propType = $prop->getType()?->getName(); + $propName = $prop->getName(); + + if ($propType && property_exists($controller, $propName)) { + $controllerPropType = (new \ReflectionProperty($controller, $propName))->getType()?->getName(); + + // Inject only if types match + if ($controllerPropType && $controllerPropType === $propType) { + $mw->$propName = $controller->$propName; + } + } + } + + // and execute before() middleware functions + if (method_exists($mw, 'before')) { + $p = call_user_func([$mw, 'before'], $method, $fn, $params); + + if ($p) { + $params = $p; + } + } } - $response = call_user_func_array(array($controller, $fn), $params); + $response = call_user_func_array([$controller, $fn], $params); + + // execute after() middleware functions in reverse order + foreach (array_reverse($middlewares) as $mw) { + $afterResult = call_user_func([$mw, 'after'], $method, $fn, $params, $response); + if ($afterResult !== null) { + $response = $afterResult; // allow after() to modify result + } + } // if client wants json, return right away - response will be encoded by route() and respond() if (!empty($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'json')) { diff --git a/src/Router/Middleware.php b/src/Router/Middleware.php new file mode 100644 index 0000000..0483e66 --- /dev/null +++ b/src/Router/Middleware.php @@ -0,0 +1,13 @@ + Date: Sun, 12 Oct 2025 10:39:32 -0300 Subject: [PATCH 05/64] fix: description --- src/Router.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Router.php b/src/Router.php index f5aec36..e0afabc 100644 --- a/src/Router.php +++ b/src/Router.php @@ -241,14 +241,13 @@ public function controller($path, $controller) $middlewares[get_class($middleware)] = $middleware; } - // Method Middlewares + // Method Middlewares (override class middlewares if they exist with the same class name) foreach ($refMethod->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF) as $attr) { $middleware = $attr->newInstance(); unset($middlewares[get_class($middleware)]); // ensure method middleware is inserted after class middlewares $middlewares[get_class($middleware)] = $middleware; } - foreach ($middlewares as $mw) { // Auto-inject dependencies from the controller $attrRef = new \ReflectionObject($mw); From 2f2c4a93eaa402668cff213d5ac5388795155217 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 20 Dec 2025 15:29:15 -0300 Subject: [PATCH 06/64] feat: support templates/_index.php --- src/Router.php | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/Router.php b/src/Router.php index e0afabc..d98e2fe 100644 --- a/src/Router.php +++ b/src/Router.php @@ -294,14 +294,16 @@ public function controller($path, $controller) $_SCRIPT_DIR = dirname($_SERVER['SCRIPT_NAME']); $_SCRIPT_NAME = basename($_SERVER['SCRIPT_NAME'], '.php'); - $template_root = sprintf("%s/templates%s%s%s", - dirname(dirname(dirname(dirname(__DIR__)))), + $template_root = sprintf("%s/templates", + dirname(dirname(dirname(dirname(__DIR__))))); + + $template_path = sprintf("%s%s%s", $_SCRIPT_DIR == '/' ? '' : $_SCRIPT_DIR, $_SCRIPT_NAME == 'index' ? '' : '/' . $_SCRIPT_NAME, $path != '/' ? $path . '/' : $path ); - $templates = array_unique(["$template_root$fn.php", "$template_root$method.php"]); + $templates = array_unique(["$template_root$template_path$fn.php", "$template_root$template_path$method.php"]); foreach ($templates as $template) { if (is_readable($template)) { @@ -309,7 +311,14 @@ public function controller($path, $controller) include "$template_root/_functions.php"; } + if (is_readable("$template_root/_index.php") ) { + $response['_template'] = $template; + $template = "$template_root/_index.php"; + } + + error_log(json_encode($response)); return Router::render($template, $response); + } } @@ -565,18 +574,19 @@ public static function respond($content, $code = 200) exit($content); } - public static function render($_template, $_data = []) + public static function render($_main_template, $_data = []) { - if (!is_readable($_template)) { - throw new \Exception("Cannot read $_template", 404); + if (!is_readable($_main_template)) { + throw new \Exception("Cannot read $_main_template", 404); } + ob_start(); + if (is_array($_data)) { extract($_data); } - ob_start(); - include $_template; + include $_main_template; $contents = ob_get_contents(); ob_end_clean(); From 20284981b7817af2c7f3676ccbcb2997a745295e Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 21 Dec 2025 17:50:15 -0300 Subject: [PATCH 07/64] Move Middleware to use DI --- src/Router.php | 51 ++++++++++++++------------------------- src/Router/Middleware.php | 4 +-- 2 files changed, 20 insertions(+), 35 deletions(-) diff --git a/src/Router.php b/src/Router.php index d98e2fe..799108a 100644 --- a/src/Router.php +++ b/src/Router.php @@ -148,7 +148,7 @@ public function controller($path, $controller) // url parameters $params = explode("/", $params); - // An GET $path/1/2/3/4 request will be parsed into + // A GET $path/1/2/3/4 request will be parsed into // $method = GET // $params = [ 1, 2, 3, 4 ] @@ -237,41 +237,25 @@ public function controller($path, $controller) // Class Middlewares foreach ($refClass->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF) as $attr) { - $middleware = $attr->newInstance(); - $middlewares[get_class($middleware)] = $middleware; + $middlewares[$attr->getName()] = $attr->getArguments(); } // Method Middlewares (override class middlewares if they exist with the same class name) foreach ($refMethod->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF) as $attr) { - $middleware = $attr->newInstance(); - unset($middlewares[get_class($middleware)]); // ensure method middleware is inserted after class middlewares - $middlewares[get_class($middleware)] = $middleware; + unset($middlewares[$attr->getName()]); // ensure method middleware is inserted after class middlewares + $middlewares[$attr->getName()] = $attr->getArguments(); } - foreach ($middlewares as $mw) { - // Auto-inject dependencies from the controller - $attrRef = new \ReflectionObject($mw); - foreach ($attrRef->getProperties() as $prop) { - $propType = $prop->getType()?->getName(); - $propName = $prop->getName(); + // With the middlewares list, let's instantiate and execute each + foreach ($middlewares as $mw_class => $mw_args) { + // instantiate middleware + $mw = $this->create($mw_class, $mw_args, [ get_class($controller).$mw_class ]); - if ($propType && property_exists($controller, $propName)) { - $controllerPropType = (new \ReflectionProperty($controller, $propName))->getType()?->getName(); + $middlewares[$mw_class] = $mw; - // Inject only if types match - if ($controllerPropType && $controllerPropType === $propType) { - $mw->$propName = $controller->$propName; - } - } - } - - // and execute before() middleware functions + // execute before() middleware functions if (method_exists($mw, 'before')) { - $p = call_user_func([$mw, 'before'], $method, $fn, $params); - - if ($p) { - $params = $p; - } + $params = call_user_func([$mw, 'before'], $method, $fn, $params); } } @@ -279,14 +263,15 @@ public function controller($path, $controller) // execute after() middleware functions in reverse order foreach (array_reverse($middlewares) as $mw) { - $afterResult = call_user_func([$mw, 'after'], $method, $fn, $params, $response); - if ($afterResult !== null) { - $response = $afterResult; // allow after() to modify result - } + $response = call_user_func([$mw, 'after'], $method, $fn, $params, $response); } - // if client wants json, return right away - response will be encoded by route() and respond() - if (!empty($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'json')) { + // if response is an object OR if the client wants json, return right away + // the response will be encoded by route() and respond() + if ( + (is_object($response) && is_callable([$response, 'render'])) + || (!empty($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'json')) + ) { return $response; } diff --git a/src/Router/Middleware.php b/src/Router/Middleware.php index 0483e66..1da352a 100644 --- a/src/Router/Middleware.php +++ b/src/Router/Middleware.php @@ -4,10 +4,10 @@ class Middleware { public function before($method, $fn, $params): mixed { - return null; + return $params; } public function after($method, $fn, $params, $response): mixed { - return null; + return $response; } } \ No newline at end of file From 7e841e83b2f7746a9b6ae3c95f9c9c647ed5eb40 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 21 Dec 2025 17:50:52 -0300 Subject: [PATCH 08/64] Add Router/Template --- src/Router.php | 35 ++---------------- src/Router/Template.php | 78 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 src/Router/Template.php diff --git a/src/Router.php b/src/Router.php index 799108a..505e71a 100644 --- a/src/Router.php +++ b/src/Router.php @@ -4,6 +4,7 @@ use JMS\Serializer\SerializationContext; use Objectiveweb\Router\Middleware; +use Objectiveweb\Router\Template; class Router { @@ -292,22 +293,11 @@ public function controller($path, $controller) foreach ($templates as $template) { if (is_readable($template)) { - if (is_readable("$template_root/_functions.php")) { - include "$template_root/_functions.php"; - } - - if (is_readable("$template_root/_index.php") ) { - $response['_template'] = $template; - $template = "$template_root/_index.php"; - } - - error_log(json_encode($response)); - return Router::render($template, $response); - + return new Template($template, $response); } } - // in case no template is available, return the response + // in case no template is available, return the raw response return $response; }); } @@ -559,25 +549,6 @@ public static function respond($content, $code = 200) exit($content); } - public static function render($_main_template, $_data = []) - { - if (!is_readable($_main_template)) { - throw new \Exception("Cannot read $_main_template", 404); - } - - ob_start(); - - if (is_array($_data)) { - extract($_data); - } - - include $_main_template; - $contents = ob_get_contents(); - ob_end_clean(); - - return $contents; - } - public static function isAjax() { return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'; diff --git a/src/Router/Template.php b/src/Router/Template.php new file mode 100644 index 0000000..4e82435 --- /dev/null +++ b/src/Router/Template.php @@ -0,0 +1,78 @@ +_template)) { + throw new \Exception("Cannot read $this->_template", 500); + } + + if (is_array($this->_data)) { + extract($this->_data); + } + + ob_start(); + + include $this->_template; + + $_contents = ob_get_contents(); + + ob_end_clean(); + + if($this->_layout) { + if(is_readable(self::$root . '/_layouts/' . $this->_layout . '.php')) { + + ob_start(); + include self::$root . '/_layouts/' . $this->_layout . '.php'; + + $_contents = ob_get_contents(); + ob_end_clean(); + } + else { + throw new \Exception("Cannot read $this->_layout", 500); + } + + } + + return $_contents; + } +} From d0ee3a46ccafe3a96269c3b36f7da4ed9d09266d Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 21 Dec 2025 17:56:57 -0300 Subject: [PATCH 09/64] Update docs --- README.md | 2 +- doc/controller.md | 129 ++++++++++++++++++++++ docs/middleware.md | 262 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 doc/controller.md create mode 100644 docs/middleware.md diff --git a/README.md b/README.md index 5e6d391..11bdfd3 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ You can also fetch the twig reference using $twig = $app->create('Twig_Environment'); -### Rules +### Dependency Injection Rules Dice Rules can be configured with these properties: diff --git a/doc/controller.md b/doc/controller.md new file mode 100644 index 0000000..489555e --- /dev/null +++ b/doc/controller.md @@ -0,0 +1,129 @@ +# Controller Mapping in Objectiveweb Router + +The Objectiveweb Router framework uses a sophisticated controller mapping system that automatically routes HTTP requests to appropriate controller methods based on the URL structure and HTTP method. + +## How Requests are Mapped to Controllers + +### Controller Registration + +Controllers are registered using the `controller()` method in the router. This method takes two parameters: +1. `$path` - The URL path prefix (e.g., `/vouchers`, `/venues`) +2. `$controller` - The controller class name or instance + +Example from `public/index.php`: +```php +$app->controller('/auth', \Objectiveweb\Auth\Controller\OAuthController::class); +$app->controller('/admin/users', \Breakfastweekend\App\Controller\Admin\UsersController::class); +$app->controller('/vouchers', \Breakfastweekend\App\Controller\VouchersController::class); +$app->controller('/venues', \Breakfastweekend\App\Controller\VenuesController::class); +``` + +### Request Routing Logic + +When a request is made to a registered controller path, the router follows these steps: + +1. **HTTP Method Detection**: The router detects the HTTP method (GET, POST, PUT, DELETE, etc.) + +2. **URL Parameter Parsing**: URL path parameters are extracted from the request path + +3. **Method Resolution**: The router determines which controller method to call based on: + - The HTTP method (GET, POST, PUT, DELETE) + - URL parameters (if any) + - The controller's available methods + +### Method Resolution Rules + +The router follows these rules to determine which method to call: + +#### 1. Base Methods +- **GET /path/** → calls `index()` method +- **POST /path/** → calls `post()` method +- **PUT /path/** → calls `put()` method +- **DELETE /path/** → calls `delete()` method + +#### 2. Parameter-Based Methods +When URL parameters are present, the router tries to match them to controller methods: + +- **GET /path/123** → calls `get(123)` method +- **POST /path/123** → calls `post(123)` method +- **PUT /path/123** → calls `put(123)` method +- **DELETE /path/123** → calls `delete(123)` method + +#### 3. Custom Method Resolution +If URL parameters match controller method names, the router will call that specific method: + +- **GET /path/some-action** → calls `someAction()` method +- **POST /path/some-action** → calls `postSomeAction()` method + +#### 4. Special Cases +- **GET /path/** with no parameters → calls `index()` method +- **GET /path/123** with no matching method → calls `get(123)` method + +## Example Request Mappings + +### Vouchers Controller Examples + +Given the registration: `$app->controller('/vouchers', \Breakfastweekend\App\Controller\VouchersController::class);` + +| Request | URL Path | Method Called | +|---------|----------|---------------| +| GET `/vouchers` | `/vouchers` | `index()` | +| GET `/vouchers/123` | `/vouchers/123` | `get(123)` | +| POST `/vouchers/123` | `/vouchers/123` | `post(123)` | +| POST `/vouchers` | `/vouchers` | `post()` | +| PUT `/vouchers/123` | `/vouchers/123` | `put(123)` | +| DELETE `/vouchers/123` | `/vouchers/123` | `delete(123)` | + +### Custom Method Examples + +Given a controller with methods like `activate()` and `postActivate()`: + +| Request | URL Path | Method Called | +|---------|----------|---------------| +| GET `/vouchers/activate` | `/vouchers/activate` | `activate()` | +| POST `/vouchers/activate` | `/vouchers/activate` | `postActivate()` | + +## HTTP Method Handling + +The router automatically handles different HTTP methods and passes appropriate data: + +### GET Requests +- Parameters from URL path are passed as arguments +- `$_GET` parameters are appended to method arguments + +### POST/PUT/PATCH Requests +- Parameters from URL path are passed as arguments +- Request body is parsed and passed as the last argument +- For type-hinted methods, the body is automatically deserialized using JMS Serializer + +### DELETE Requests +- Parameters from URL path are passed as arguments +- `$_GET` parameters are appended to method arguments + +## Middleware Support + +Controllers can define middleware using attributes: +- Class-level middleware applies to all methods +- Method-level middleware overrides class middleware + +Middleware classes are instantiated using the dependency injection container (`$this->create`) instead of manual instantiation, ensuring that dependencies are properly injected into middleware classes, similar to how controllers are instantiated. + +## Template Rendering + +If a template exists for the method being called, the router will automatically render it: +- Templates are located in `templates/` directory +- The path structure follows the controller registration pattern + +## Error Handling + +If no matching method is found: +- Returns 404 error with "Route not found" message +- If CORS is enabled, OPTIONS requests are handled appropriately + +## Authentication Integration + +Controllers can use `#[RequireRole]` attributes to control access: +- Role-based access control is enforced before method execution +- Authentication is handled automatically through the framework + +This controller mapping system provides a clean, predictable way to route requests to appropriate controller methods while maintaining flexibility for complex routing scenarios. diff --git a/docs/middleware.md b/docs/middleware.md new file mode 100644 index 0000000..a572669 --- /dev/null +++ b/docs/middleware.md @@ -0,0 +1,262 @@ +# Middleware System in Objectiveweb Router + +The Objectiveweb Router framework provides a flexible middleware system that allows you to intercept and modify HTTP requests and responses before and after controller method execution. + +## Overview + +Middleware is implemented as PHP classes that can be applied to controllers or specific controller methods using attributes. The middleware system provides hooks for pre-processing requests (`before` method) and post-processing responses (`after` method). + +## Middleware Class Structure + +All middleware classes must extend the base `Objectiveweb\Router\Middleware` class: + +```php +create`) instead of manual instantiation. This ensures that dependencies are properly injected into middleware classes, similar to how controllers are instantiated. + +In the controller method, middleware instantiation happens like this: + +```php +// With the middlewares list, let's instantiate and execute each +foreach ($middlewares as $mw_class => $mw_args) { + // instantiate middleware + $mw = $this->create($mw_class, $mw_args, [ get_class($controller).$mw_class ]); + + $middlewares[$mw_class] = $mw; + + // execute before() middleware functions + if (method_exists($mw, 'before')) { + $params = call_user_func([$mw, 'before'], $method, $fn, $params); + } +} +``` + +This approach ensures that middleware classes can receive dependencies through constructor injection, just like controllers do. + +## Middleware Execution Flow + +When a request is processed: + +1. **Class-level middleware** is executed first (in the order they are defined) +2. **Method-level middleware** is executed after class middleware (if present) +3. **Before hooks** are called with the HTTP method, function name, and parameters +4. **Controller method** is executed +5. **After hooks** are called with the HTTP method, function name, parameters, and response + +## Method Signatures + +### `before($method, $fn, $params): mixed` + +- `$method`: HTTP method (GET, POST, PUT, DELETE, etc.) +- `$fn`: Controller method name being called +- `$params`: Array of parameters passed to the controller method +- Returns: Modified parameters array + +### `after($method, $fn, $params, $response): mixed` + +- `$method`: HTTP method (GET, POST, PUT, DELETE, etc.) +- `$fn`: Controller method name being called +- `$params`: Array of parameters passed to the controller method +- `$response`: The response returned by the controller method +- Returns: Modified response + +## Example Implementation + +Here's a complete example of a middleware implementation: + +```php + 'Hello World']; + } + + #[LoggingMiddleware] + public function get($id) + { + return ['id' => $id, 'data' => 'Some data']; + } +} +``` + +## Authentication Middleware Example + +The framework includes a built-in authentication middleware example: + +```php +role = is_array($role) ? $role : [$role]; + } + + public function after($method, $fn, $params, $response): mixed + { + if ($this->auth->check() && is_array($response) && !isset($response['_user'])) { + $response['_user'] = $this->auth->user(); + } + + return $response; + } + + public function before($method, $fn, $params): mixed + { + if ($this->auth->check()) { + $scopes = \Objectiveweb\Auth::AUTHENTICATED; + + $this->user = $this->auth->user(); + + if (is_array($this->user['scopes'])) { + $scopes = array_merge($scopes, $this->user['scopes']); + } + } else { + $scopes = \Objectiveweb\Auth::ANONYMOUS; + } + + if (count(array_intersect($this->role, $scopes)) == 0) { + throw new AuthException("Forbidden", $scopes[0] == 'anon' ? 401 : 403); + } + + return $params; + } +} +``` + +## Best Practices + +1. **Middleware Order**: Class-level middleware executes before method-level middleware +2. **Parameter Modification**: Use `before()` to modify request parameters before controller execution +3. **Response Modification**: Use `after()` to modify response data after controller execution +4. **Error Handling**: Middleware can throw exceptions that will be handled by the router +5. **Dependency Injection**: Middleware can receive dependencies through constructor injection + +## Advanced Usage + +Middleware can also receive dependencies from the controller: + +```php +auth->check()) { + // Do something with auth + } + + return $params; + } +} From f98b83abbfc99a01eeacaf67cf89a4a6302df11a Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 21 Dec 2025 23:52:34 -0300 Subject: [PATCH 10/64] New Middleware syntax While initializing Attributes with Dice, linting got broken since the Attribute misses the arguments that will be injected by DI. This changes syntax to calling #[Middleware(class, ...args)] instead of referencing the Attribute directly. --- docs/middleware.md | 145 +++++------------------------ src/Router.php | 9 +- src/Router/Middleware.php | 18 +++- src/Router/MiddlewareInterface.php | 21 +++++ 4 files changed, 66 insertions(+), 127 deletions(-) create mode 100644 src/Router/MiddlewareInterface.php diff --git a/docs/middleware.md b/docs/middleware.md index a572669..65dd81c 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -8,22 +8,30 @@ Middleware is implemented as PHP classes that can be applied to controllers or s ## Middleware Class Structure -All middleware classes must extend the base `Objectiveweb\Router\Middleware` class: +All middleware classes must implement the `Objectiveweb\Router\MiddlewareInterface`: ```php create`) instead of manual instantiation. This ensures that dependencies are properly injected into middleware classes, similar to how controllers are instantiated. - -In the controller method, middleware instantiation happens like this: - -```php -// With the middlewares list, let's instantiate and execute each -foreach ($middlewares as $mw_class => $mw_args) { - // instantiate middleware - $mw = $this->create($mw_class, $mw_args, [ get_class($controller).$mw_class ]); - - $middlewares[$mw_class] = $mw; - - // execute before() middleware functions - if (method_exists($mw, 'before')) { - $params = call_user_func([$mw, 'before'], $method, $fn, $params); - } -} -``` - -This approach ensures that middleware classes can receive dependencies through constructor injection, just like controllers do. - ## Middleware Execution Flow When a request is processed: 1. **Class-level middleware** is executed first (in the order they are defined) 2. **Method-level middleware** is executed after class middleware (if present) -3. **Before hooks** are called with the HTTP method, function name, and parameters +3. **For each Middleware: Before hooks** are called with the HTTP method, function name, and parameters 4. **Controller method** is executed -5. **After hooks** are called with the HTTP method, function name, parameters, and response +5. **For each Middleware: After hooks** are called with the HTTP method, function name, parameters, and response ## Method Signatures @@ -125,9 +110,9 @@ Here's a complete example of a middleware implementation: namespace Breakfastweekend\App\Middleware; -use Objectiveweb\Router\Middleware; +use Objectiveweb\Router\MiddlewareInterface; -class LoggingMiddleware extends Middleware +class LoggingMiddleware implements MiddlewareInterface { public function before($method, $fn, $params): mixed { @@ -159,13 +144,13 @@ use Objectiveweb\Router\Middleware; class MyController { - #[LoggingMiddleware] + #[Middleware(LoggingMiddleware::class)] public function index() { return ['message' => 'Hello World']; } - #[LoggingMiddleware] + #[Middleware(LoggingMiddleware::class)] public function get($id) { return ['id' => $id, 'data' => 'Some data']; @@ -173,64 +158,6 @@ class MyController } ``` -## Authentication Middleware Example - -The framework includes a built-in authentication middleware example: - -```php -role = is_array($role) ? $role : [$role]; - } - - public function after($method, $fn, $params, $response): mixed - { - if ($this->auth->check() && is_array($response) && !isset($response['_user'])) { - $response['_user'] = $this->auth->user(); - } - - return $response; - } - - public function before($method, $fn, $params): mixed - { - if ($this->auth->check()) { - $scopes = \Objectiveweb\Auth::AUTHENTICATED; - - $this->user = $this->auth->user(); - - if (is_array($this->user['scopes'])) { - $scopes = array_merge($scopes, $this->user['scopes']); - } - } else { - $scopes = \Objectiveweb\Auth::ANONYMOUS; - } - - if (count(array_intersect($this->role, $scopes)) == 0) { - throw new AuthException("Forbidden", $scopes[0] == 'anon' ? 401 : 403); - } - - return $params; - } -} -``` - ## Best Practices 1. **Middleware Order**: Class-level middleware executes before method-level middleware @@ -238,25 +165,3 @@ class RequireRole extends Middleware 3. **Response Modification**: Use `after()` to modify response data after controller execution 4. **Error Handling**: Middleware can throw exceptions that will be handled by the router 5. **Dependency Injection**: Middleware can receive dependencies through constructor injection - -## Advanced Usage - -Middleware can also receive dependencies from the controller: - -```php -auth->check()) { - // Do something with auth - } - - return $params; - } -} diff --git a/src/Router.php b/src/Router.php index 505e71a..01962b5 100644 --- a/src/Router.php +++ b/src/Router.php @@ -238,13 +238,16 @@ public function controller($path, $controller) // Class Middlewares foreach ($refClass->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF) as $attr) { - $middlewares[$attr->getName()] = $attr->getArguments(); + /** @var Middleware $mw */ + $mw = $attr->newInstance(); + $middlewares[$mw->getClass()] = $mw->getArgs(); } // Method Middlewares (override class middlewares if they exist with the same class name) foreach ($refMethod->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF) as $attr) { - unset($middlewares[$attr->getName()]); // ensure method middleware is inserted after class middlewares - $middlewares[$attr->getName()] = $attr->getArguments(); + $mw = $attr->newInstance(); + unset($middlewares[$mw->getClass()]); // ensure method middleware is inserted after class middlewares + $middlewares[$mw->getClass()] = $mw->getArgs(); } // With the middlewares list, let's instantiate and execute each diff --git a/src/Router/Middleware.php b/src/Router/Middleware.php index 1da352a..bbc9c21 100644 --- a/src/Router/Middleware.php +++ b/src/Router/Middleware.php @@ -2,12 +2,22 @@ namespace Objectiveweb\Router; +use Attribute; + +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Middleware { - public function before($method, $fn, $params): mixed { - return $params; + + private $args; + + public function __construct(private string $class, ...$args) { + $this->args = $args; + } + + public function getClass() { + return $this->class; } - public function after($method, $fn, $params, $response): mixed { - return $response; + public function getArgs() { + return $this->args; } } \ No newline at end of file diff --git a/src/Router/MiddlewareInterface.php b/src/Router/MiddlewareInterface.php new file mode 100644 index 0000000..5283ccc --- /dev/null +++ b/src/Router/MiddlewareInterface.php @@ -0,0 +1,21 @@ + Date: Mon, 29 Dec 2025 16:18:39 -0300 Subject: [PATCH 11/64] Pass matched parameters to the Controller constructor --- src/Router.php | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/Router.php b/src/Router.php index 01962b5..c06cbc6 100644 --- a/src/Router.php +++ b/src/Router.php @@ -56,13 +56,6 @@ public function create(string $name, array $args = [], array $share = []) */ public function route($request, $callback) { - if (is_array($callback) && is_string($callback[0])) { - $callback[0] = $this->create($callback[0]); - } - - if (!is_callable($callback)) { - throw new \Exception(sprintf(_('%s: Invalid callback'), $callback), 500); - } // support PATH_INFO when using mod_rewrite if (empty($_SERVER['REDIRECT_URL'])) { @@ -78,10 +71,25 @@ public function route($request, $callback) $_SERVER['PATH_INFO'] = (empty($m[2]) || $m[2][0] != '/') ? '/' . $m[2] : $m[2]; } - if (preg_match(sprintf("/^%s$/", str_replace('/', '\/', $request)), "{$_SERVER['REQUEST_METHOD']} {$_SERVER['PATH_INFO']}", $params)) { + array_shift($params); + // route expects a callable + // this can be: + // - a function() + // - [ $instance, 'method' ] + // - [ 'Classname', 'method' ] + + // For the third case, we need to instantiate the class + if (is_array($callback) && is_string($callback[0])) { + $callback[0] = $this->create($callback[0], $params); + } + + if (!is_callable($callback)) { + throw new \Exception(sprintf(_('%s: Invalid callback'), $callback), 500); + } + if (func_num_args() > 2) { $params = array_merge($params, array_slice(func_get_args(), 2)); } @@ -136,9 +144,15 @@ public function controller($path, $controller) $args = func_get_args(); array_splice($args, 0, 2); - $re = sprintf("([A-Z]+) (?:$path$|%s)(.*)", $path == '/' ? '/' : $path . '/'); + $re = sprintf("([A-Z]+) %s(?:$|/)(.*)", $path); + $this->route($re, function ($method, $params) use ($re, $path, $controller, $args) { + if (func_num_args() > 2) { + $callback_args = func_get_args(); + array_splice($callback_args, 0, 1); - $this->route($re, function ($method, $params) use ($path, $controller, $args) { + $params = array_pop($callback_args); + $args = [...$args, ...$callback_args]; + } if (is_string($controller)) { $controller = $this->create($controller, $args); From d1b9200a33acbcdcf6963e2fa64e00054136504f Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Mon, 29 Dec 2025 16:53:27 -0300 Subject: [PATCH 12/64] Improve Template logic - Instantiate from Router - Set template.root and default middlewares in Router --- src/Router.php | 73 +++++++++++++++++++++++++++++++++-------- src/Router/Template.php | 25 ++++++-------- 2 files changed, 69 insertions(+), 29 deletions(-) diff --git a/src/Router.php b/src/Router.php index c06cbc6..95a77b7 100644 --- a/src/Router.php +++ b/src/Router.php @@ -14,9 +14,22 @@ class Router private $cors = null; private \Dice\Dice $dice; - function __construct() + function __construct(?string $_root = null, private array $config = []) { $this->dice = new \Dice\Dice(); + + // By default, set root to the project root (../../.. from vendor/ow/router) + if (!$_root) { + $_root = dirname(dirname(dirname(__DIR__))); + } + + $defaults = [ + 'middlewares' => [], + 'template.root' => $_root . '/templates', + 'template.layout' => null, + ]; + + $this->config = array_merge($defaults, $config); } function setCors($cors) @@ -34,7 +47,7 @@ static function hasSerializer($type) return !empty(self::$serializers[$type]); } - public function addRule($name, array $rule) + public function addRule($name, array $rule): void { $this->dice = $this->dice->addRule($name, $rule); } @@ -44,6 +57,35 @@ public function create(string $name, array $args = [], array $share = []) return $this->dice->create($name, $args, $share); } + /** + * Return a new Template() object based on default root and optional layout + * + * @param $names + * @param array $_data + * @param string|null $layout + * @return Template|null + * @throws \Exception + */ + public function template($names, array $_data, string|null $_layout = null): Template|null + { + $_root = $this->config["template.root"]; + $_layout = $_layout ?? $this->config["template.layout"]; + + if (is_array($names)) { + foreach ($names as $name) { + if (is_readable($_root . DIRECTORY_SEPARATOR . $name . '.php')) { + return new Template($_root, $name, $_data, $_layout); + } + } + } else { + if (is_readable($_root . DIRECTORY_SEPARATOR . $names . '.php')) { + return new Template($_root, $names, $_data, $_layout); + } + } + + return null; + } + /** * Route a particular request to a callback * @@ -192,6 +234,7 @@ public function controller($path, $controller) } if (!is_callable(array($controller, $fn))) { + // TODO move to Middleware if ($this->cors && $fn == 'options' && isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD']) && isset($_SERVER['HTTP_ORIGIN'])) { @@ -248,7 +291,10 @@ public function controller($path, $controller) } // Check middlewares - $middlewares = []; + + // Start with default set of middlewares (applied to all requests) + // Note: will be overridden by class Attributes if mw class is the same + $middlewares = $this->config['middlewares']; // Class Middlewares foreach ($refClass->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF) as $attr) { @@ -267,7 +313,7 @@ public function controller($path, $controller) // With the middlewares list, let's instantiate and execute each foreach ($middlewares as $mw_class => $mw_args) { // instantiate middleware - $mw = $this->create($mw_class, $mw_args, [ get_class($controller).$mw_class ]); + $mw = $this->create($mw_class, $mw_args, [get_class($controller) . $mw_class]); $middlewares[$mw_class] = $mw; @@ -277,6 +323,12 @@ public function controller($path, $controller) } } + // we're testing this here in case Middlewares end the request prematurely + // (needed for OPTIONS in CORS) + if (!is_callable([$controller, $fn])) { + throw new \Exception(sprintf(_("%s\\%s: Route not found"), get_class($controller), $fn), 404); + } + $response = call_user_func_array([$controller, $fn], $params); // execute after() middleware functions in reverse order @@ -297,25 +349,18 @@ public function controller($path, $controller) $_SCRIPT_DIR = dirname($_SERVER['SCRIPT_NAME']); $_SCRIPT_NAME = basename($_SERVER['SCRIPT_NAME'], '.php'); - $template_root = sprintf("%s/templates", - dirname(dirname(dirname(dirname(__DIR__))))); - $template_path = sprintf("%s%s%s", $_SCRIPT_DIR == '/' ? '' : $_SCRIPT_DIR, $_SCRIPT_NAME == 'index' ? '' : '/' . $_SCRIPT_NAME, $path != '/' ? $path . '/' : $path ); - $templates = array_unique(["$template_root$template_path$fn.php", "$template_root$template_path$method.php"]); + $templates = array_unique(["$template_path$fn", "$$template_path$method"]); - foreach ($templates as $template) { - if (is_readable($template)) { - return new Template($template, $response); - } - } + $template = $this->template($templates, $response); // in case no template is available, return the raw response - return $response; + return $template ?? $response; }); } diff --git a/src/Router/Template.php b/src/Router/Template.php index 4e82435..0c4a18f 100644 --- a/src/Router/Template.php +++ b/src/Router/Template.php @@ -13,22 +13,21 @@ */ class Template { - /** - * Root directory for layout files - * - * @var string - */ - public static $root; - /** * Template constructor - * + * + * @param string $_root Template root directory * @param string $template Path to the template file * @param array $data Data to be passed to the template * @param string|null $layout Layout file name (without extension) */ - function __construct(private $_template, private $_data = [], private $_layout = null) { + function __construct(private $_root, private string $_template, private array $_data = [], private string|null $_layout = null) { + + $this->_template = $this->_root . DIRECTORY_SEPARATOR . $_template . '.php'; + if (!is_readable($this->_template)) { + throw new \Exception("Cannot read $this->_template", 500); + } } /** @@ -42,10 +41,6 @@ function __construct(private $_template, private $_data = [], private $_layout = */ function render() { - if (!is_readable($this->_template)) { - throw new \Exception("Cannot read $this->_template", 500); - } - if (is_array($this->_data)) { extract($this->_data); } @@ -59,10 +54,10 @@ function render() { ob_end_clean(); if($this->_layout) { - if(is_readable(self::$root . '/_layouts/' . $this->_layout . '.php')) { + if(is_readable($this->_root . '/_layouts/' . $this->_layout . '.php')) { ob_start(); - include self::$root . '/_layouts/' . $this->_layout . '.php'; + include $this->_root . '/_layouts/' . $this->_layout . '.php'; $_contents = ob_get_contents(); ob_end_clean(); From 54d9195c767bf0c56bf31d22bfa207a4b0c5a129 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Fri, 2 Jan 2026 22:48:02 -0300 Subject: [PATCH 13/64] fix: don't use url() for complete redirect urls --- src/Router.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Router.php b/src/Router.php index 95a77b7..41dc2df 100644 --- a/src/Router.php +++ b/src/Router.php @@ -572,7 +572,13 @@ public static function parse_post_body($decoded = true, $as_array = true) public static function redirect($to, $code = 301) { header("HTTP/1.1 $code"); - header('Location: ' . Router::url($to)); + + if(preg_match('#^https?://#', $to)) { + header("Location: $to"); + } + else { + header('Location: ' . Router::url($to)); + } exit(); } From 6010f251356d639cc0afacdb8754e766cdfdca22 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 19 Apr 2026 16:22:27 -0300 Subject: [PATCH 14/64] Use objectiveweb/Dice --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c315df9..bcf23dc 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ ], "require": { "php": ">=7.4.0", - "level-2/dice": "^4.0" + "objectiveweb/Dice": "dev-master" }, "require-dev": { "phpunit/phpunit": "4.*", From 8063050d060cbe16a31b9a19509048263625177e Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 19 Apr 2026 20:40:57 -0300 Subject: [PATCH 15/64] fix: composer.json --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index bcf23dc..f02d88e 100644 --- a/composer.json +++ b/composer.json @@ -10,11 +10,11 @@ } ], "require": { - "php": ">=7.4.0", - "objectiveweb/Dice": "dev-master" + "php": ">=8.0", + "objectiveweb/dice": "dev-master" }, "require-dev": { - "phpunit/phpunit": "4.*", + "phpunit/phpunit": "^12.0", "jms/serializer": "~1.1" }, "license": "MIT" From 66acd8b96910a7afbff0f1d8ced06b6131b69b93 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Mon, 2 Mar 2026 21:36:08 -0300 Subject: [PATCH 16/64] Fix Template and Middleware types --- src/Router.php | 5 +++-- src/Router/MiddlewareInterface.php | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Router.php b/src/Router.php index 41dc2df..e5ece62 100644 --- a/src/Router.php +++ b/src/Router.php @@ -61,15 +61,16 @@ public function create(string $name, array $args = [], array $share = []) * Return a new Template() object based on default root and optional layout * * @param $names - * @param array $_data + * @param array|null $_data * @param string|null $layout * @return Template|null * @throws \Exception */ - public function template($names, array $_data, string|null $_layout = null): Template|null + public function template($names, ?array $_data, string|null $_layout = null): Template|null { $_root = $this->config["template.root"]; $_layout = $_layout ?? $this->config["template.layout"]; + $_data = $_data ?? []; if (is_array($names)) { foreach ($names as $name) { diff --git a/src/Router/MiddlewareInterface.php b/src/Router/MiddlewareInterface.php index 5283ccc..1dfc236 100644 --- a/src/Router/MiddlewareInterface.php +++ b/src/Router/MiddlewareInterface.php @@ -17,5 +17,5 @@ public function after( string $method, string $fn, array $params, - array|null $response): mixed; -} \ No newline at end of file + mixed $response): mixed; +} From bafe5b513f903fa4644ecf8a49ec94bb02125199 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 16:23:31 -0300 Subject: [PATCH 17/64] Encode non-string responses as JSON --- src/Router.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Router.php b/src/Router.php index e5ece62..64e7e1a 100644 --- a/src/Router.php +++ b/src/Router.php @@ -607,7 +607,9 @@ public static function respond($content, $code = 200) } else { $content = json_encode($content); } - } elseif (is_array($content)) { + } + + if (!is_string($content)) { $content = json_encode($content); } From 55267a1c04c3213bfc05601c74cadd38192e75f4 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 22:06:49 -0300 Subject: [PATCH 18/64] Build v3 CI on GitHub Actions --- .github/workflows/ci.yml | 43 ++++++++++ .travis.yml | 15 ---- README.md | 2 +- composer.json | 15 ++-- src/Router.php | 4 +- test/ControllerTest.php | 177 +++++++++++++++++---------------------- 6 files changed, 131 insertions(+), 125 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..77f04da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: + - master + - devel + pull_request: + +jobs: + test: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: + - '8.0' + - '8.1' + - '8.2' + - '8.3' + - '8.4' + - '8.5' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Validate Composer metadata + run: composer validate --strict + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run tests + run: composer test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 40b57ce..0000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -language: php - -sudo: false - -php: - - 5.6 - - 7.0 - - 7.1 - - 7.2 - - 7.3 - -before_script: - - composer update - -script: vendor/bin/phpunit test/ diff --git a/README.md b/README.md index 11bdfd3..9bf5303 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Objectiveweb URL Router ![Build Status](https://travis-ci.org/objectiveweb/router.svg?branch=master) +# Objectiveweb URL Router [![CI](https://github.com/objectiveweb/router/actions/workflows/ci.yml/badge.svg)](https://github.com/objectiveweb/router/actions/workflows/ci.yml) Lightweight url router with dependency injection support. diff --git a/composer.json b/composer.json index f02d88e..8273814 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,9 @@ "name": "objectiveweb/router", "description": "Lightweight URL Router", "autoload": { - "psr-4": { "Objectiveweb\\": "src/" } + "psr-4": { + "Objectiveweb\\\\": "src/" + } }, "authors": [ { @@ -10,12 +12,15 @@ } ], "require": { - "php": ">=8.0", - "objectiveweb/dice": "dev-master" + "php": ">=8.0", + "objectiveweb/dice": "^4.0.4" }, "require-dev": { - "phpunit/phpunit": "^12.0", - "jms/serializer": "~1.1" + "phpunit/phpunit": "^9.6 || ^10.5 || ^11.5 || ^12.5", + "jms/serializer": "^3.32" + }, + "scripts": { + "test": "phpunit test/" }, "license": "MIT" } diff --git a/src/Router.php b/src/Router.php index 64e7e1a..c330314 100644 --- a/src/Router.php +++ b/src/Router.php @@ -143,7 +143,7 @@ public function route($request, $callback) if (is_object($response) && self::hasSerializer(get_class($response))) { self::$serializers[get_class($response)]($response); } else { - self::respond($response); + static::respond($response); } } } catch (\Exception $ex) { @@ -153,7 +153,7 @@ public function route($request, $callback) if ($ex->getCode() >= 500) { error_log(get_class($ex) . ' ' . $ex->getMessage() . " @ " . $ex->getTraceAsString()); } - self::respond(['exception' => get_class($ex), 'message' => $ex->getMessage()], $ex->getCode()); + static::respond(['exception' => get_class($ex), 'message' => $ex->getMessage()], $ex->getCode()); } } } diff --git a/test/ControllerTest.php b/test/ControllerTest.php index 11494ab..8c30e69 100644 --- a/test/ControllerTest.php +++ b/test/ControllerTest.php @@ -8,174 +8,147 @@ require dirname(__DIR__) . '/example/App/Model/Product.php'; require __DIR__ . '/TestableRouter.php'; -use App\ProductsController; use App\Model\Product; +use App\ProductsController; +use PHPUnit\Framework\TestCase; use Test\Router; -class ControllerTest extends PHPUnit_Framework_TestCase +class ControllerTest extends TestCase { + private Router $app; - /** @var ProductsController */ - static protected $controller; - - /** @var Router */ - static protected $app; - - public static function setUpBeforeClass() + protected function setUp(): void { - self::$app = new Router(); - self::$app->addRule('App\DB\ProductsRepository', [ + $this->app = new Router(); + $this->app->addRule('App\\DB\\ProductsRepository', [ 'shared' => true, 'constructParams' => [ - array( - new Product(1, "Cassete Recorder", 100.00), - new Product(2, "Tractor Beam", 7.99) - ) - ] + [ + new Product(1, 'Cassete Recorder', 100.00), + new Product(2, 'Tractor Beam', 7.99), + ], + ], ]); + + $_GET = []; + $_POST = []; + + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['REDIRECT_URL'] = '/'; + unset($_SERVER['HTTP_ACCEPT']); + + global $response_value, $response_code; + $response_value = null; + $response_code = null; } - public static function route($method, $path) + private function route(string $method, string $path): void { - $_SERVER['PATH_INFO'] = $path; $_SERVER['REQUEST_METHOD'] = $method; + $_SERVER['REQUEST_URI'] = $path; + $_SERVER['REDIRECT_URL'] = $path; - self::$app->controller("/", 'App\ProductsController', 'TEST'); + $this->app->controller('/', ProductsController::class, 'TEST'); } - public function testIndex() + public function testIndex(): void { global $response_value; - self::route("GET", "/"); - - $this->assertEquals(2, count($response_value)); - $this->assertEquals(1, $response_value[0]->sku); + $this->route('GET', '/'); + $this->assertCount(2, $response_value); + $this->assertSame(1, $response_value[0]->sku); } - public function testGet() + public function testGet(): void { global $response_value; - self::route("GET", "/2"); - - $this->assertEquals(2, $response_value->sku); - } - - public function testBeforePost() - { - global $response_code; - self::route("POST", "/"); - $this->assertEquals(403, $response_code); + $this->route('GET', '/2'); + $this->assertSame(2, $response_value->sku); } - /** - * @requires HHVM - */ - public function testPost() + public function testPost(): void { global $response_value; - $controller = self::$app->create('App\ProductsController'); - $repository = self::$app->create('App\DB\ProductsRepository'); - - $controller->auth = true; + $controller = $this->app->create(ProductsController::class); + $repository = $this->app->create('App\\DB\\ProductsRepository'); $_POST = '{ "sku" : 10, "name" : "Test Product", "price" : 89.99 }'; + $_SERVER['PATH_INFO'] = '/'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['REDIRECT_URL'] = '/'; - $_SERVER['PATH_INFO'] = "/"; - $_SERVER['REQUEST_METHOD'] = "POST"; - try { - self::$app->controller("/", $controller); - } catch (\Exception $ex) { - exit($ex->getMessage()); - } - - if (is_object($response_value)) { // this test fails on travis - $this->assertEquals('App\Model\Product', get_class($response_value)); - } - - $this->assertEquals(3, $repository->count()); - $v = $repository->get(10); - $this->assertEquals(89.99, $v->price); - + $this->app->controller('/', $controller); + $this->assertInstanceOf(Product::class, $response_value); + $this->assertSame(3, $repository->count()); + $this->assertSame(89.99, $repository->get(10)->price); } - /** - * @requires HHVM - */ - public function testPut() + public function testPut(): void { global $response_value; - $repository = self::$app->create('App\DB\ProductsRepository'); - $controller = self::$app->create('App\ProductsController'); - $controller->auth = true; + $repository = $this->app->create('App\\DB\\ProductsRepository'); + $controller = $this->app->create(ProductsController::class); $_POST = '{ "name" : "Test Rename", "price" : 89.99 }'; + $_SERVER['PATH_INFO'] = '/2'; + $_SERVER['REQUEST_METHOD'] = 'PUT'; + $_SERVER['REQUEST_URI'] = '/2'; + $_SERVER['REDIRECT_URL'] = '/2'; - $_SERVER['PATH_INFO'] = "/10"; - $_SERVER['REQUEST_METHOD'] = "PUT"; - - self::$app->controller("/", $controller); - if (is_object($response_value)) { - $this->assertEquals("Test Rename", $response_value->name); - } - - $e = $repository->get(10); - $this->assertEquals("Test Rename", $e->name); + $this->app->controller('/', $controller); + $this->assertSame('Test Rename', $response_value->name); + $this->assertSame('Test Rename', $repository->get(2)->name); } - public function testCustomMethod() + public function testCustomMethod(): void { global $response_value; - // will call $controller->getSale - self::route("GET", "/sale"); + $this->route('GET', '/sale'); - $this->assertEquals(90, $response_value[0]->price); + $this->assertSame(90, $response_value[0]->price); } - public function testControllerParameters() + public function testControllerParameters(): void { global $response_value; - self::route("GET", "/hello"); - $this->assertEquals("Hello TEST", $response_value); + + $this->route('GET', '/hello'); + + $this->assertSame('Hello TEST', $response_value); } - public function testCustomMethodFallback() + public function testCustomMethodFallback(): void { global $response_value; - // will call $controller->sale() as there is no $controller->viewSale() defined - self::route("VIEW", "/sale/8777"); + $this->route('VIEW', '/sale/8777'); - $this->assertEquals(8777, $response_value[0]->price); + $this->assertSame(8777, $response_value[0]->price); } - public function testAppRun() + public function testAppRun(): void { global $response_value; - $_SERVER['PATH_INFO'] = "/say/hello"; - $_SERVER['REQUEST_METHOD'] = "GET"; - - self::$app->run('App'); - - $this->assertEquals('hello', $response_value); - -// $_SERVER['PATH_INFO'] = "/products"; -// $_SERVER['REQUEST_METHOD'] = "GET"; + $_SERVER['PATH_INFO'] = '/say/hello'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/say/hello'; + $_SERVER['REDIRECT_URL'] = '/say/hello'; -// self::$app->run('App'); -// print_r(Router::$response); -// $this->assertEquals(2, count(Router::$response)); -// $this->assertEquals(1, Router::$response[0]['sku']); + $this->app->run('App'); + $this->assertSame('hello', $response_value); } -} \ No newline at end of file +} From 2acf3762c61a7e70d060f3d97a340fb037451ce2 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 22:07:07 -0300 Subject: [PATCH 19/64] Fix Composer autoload namespace --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 8273814..5ef67b3 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Lightweight URL Router", "autoload": { "psr-4": { - "Objectiveweb\\\\": "src/" + "Objectiveweb\\": "src/" } }, "authors": [ From 08115d5bb678c5f006078387bc5b9ff5bb335154 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 22:07:46 -0300 Subject: [PATCH 20/64] Configure Objectiveweb Dice repository --- composer.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5ef67b3..8bcd672 100644 --- a/composer.json +++ b/composer.json @@ -22,5 +22,11 @@ "scripts": { "test": "phpunit test/" }, - "license": "MIT" + "license": "MIT", + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/objectiveweb/Dice" + } + ] } From 510cb04f8b7f06a9a166e7578b847c2b9e62bf4a Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 22:08:58 -0300 Subject: [PATCH 21/64] Only render array responses through templates --- src/Router.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Router.php b/src/Router.php index c330314..87a14ad 100644 --- a/src/Router.php +++ b/src/Router.php @@ -337,11 +337,11 @@ public function controller($path, $controller) $response = call_user_func([$mw, 'after'], $method, $fn, $params, $response); } - // if response is an object OR if the client wants json, return right away - // the response will be encoded by route() and respond() + // Templates receive arrays as their data context. Other response types + // are already complete response values and should be handled by respond(). if ( - (is_object($response) && is_callable([$response, 'render'])) - || (!empty($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'json')) + !is_array($response) + || (!empty($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'json') !== false) ) { return $response; } From 5fc0fee82bec9e230b67773ce0e35bd1932a40d4 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 22:09:58 -0300 Subject: [PATCH 22/64] Fix root controller path matching --- src/Router.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Router.php b/src/Router.php index 87a14ad..ef27112 100644 --- a/src/Router.php +++ b/src/Router.php @@ -187,7 +187,9 @@ public function controller($path, $controller) $args = func_get_args(); array_splice($args, 0, 2); - $re = sprintf("([A-Z]+) %s(?:$|/)(.*)", $path); + $re = $path === '/' + ? '([A-Z]+) /(.*)' + : sprintf("([A-Z]+) %s(?:$|/)(.*)", rtrim($path, '/')); $this->route($re, function ($method, $params) use ($re, $path, $controller, $args) { if (func_num_args() > 2) { $callback_args = func_get_args(); From d7f0d2e5c2f46c2484d1065568027a161c0dfbb4 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 22:11:21 -0300 Subject: [PATCH 23/64] Use JMS attributes in example model --- example/App/Model/Product.php | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/example/App/Model/Product.php b/example/App/Model/Product.php index 6354e73..97bb3d6 100644 --- a/example/App/Model/Product.php +++ b/example/App/Model/Product.php @@ -4,20 +4,21 @@ use JMS\Serializer\Annotation\Type; -class Product { - - /** @Type("integer") */ +class Product +{ + #[Type('integer')] public $sku; - - /** @Type("string") */ + + #[Type('string')] public $name; - - /** @Type("double") */ + + #[Type('double')] public $price; - - function __construct($sku, $name, $price) { + + public function __construct($sku, $name, $price) + { $this->sku = $sku; $this->name = $name; $this->price = $price; } -} \ No newline at end of file +} From 44a52ea0092c04e1e477f397e87a40013ecdde3c Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 22:11:23 -0300 Subject: [PATCH 24/64] Treat route parameters as strings in tests --- test/ControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ControllerTest.php b/test/ControllerTest.php index 8c30e69..d84d2ac 100644 --- a/test/ControllerTest.php +++ b/test/ControllerTest.php @@ -135,7 +135,7 @@ public function testCustomMethodFallback(): void $this->route('VIEW', '/sale/8777'); - $this->assertSame(8777, $response_value[0]->price); + $this->assertSame('8777', $response_value[0]->price); } public function testAppRun(): void From e7121beeb6e0e1a89966265fa5ff6466419157a4 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 28 Sep 2026 11:39:59 -0300 Subject: [PATCH 25/64] Drop PHP 8.0 support --- .github/workflows/ci.yml | 1 - composer.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77f04da..a1d2eb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,6 @@ jobs: fail-fast: false matrix: php: - - '8.0' - '8.1' - '8.2' - '8.3' diff --git a/composer.json b/composer.json index 8bcd672..acf80ba 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ } ], "require": { - "php": ">=8.0", + "php": ">=8.1", "objectiveweb/dice": "^4.0.4" }, "require-dev": { From 77e7181304fc8ea36934d0996e50316dcf42b98d Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 28 Sep 2026 11:42:41 -0300 Subject: [PATCH 26/64] Require PHPUnit 10+ --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index acf80ba..dfe99ad 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "objectiveweb/dice": "^4.0.4" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^10.5 || ^11.5 || ^12.5", + "phpunit/phpunit": "^10.5 || ^11.5 || ^12.5", "jms/serializer": "^3.32" }, "scripts": { From 45058fb8b3ab07cdc9614f8cce5d1a3571b1d9a6 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 28 Sep 2026 14:52:10 -0300 Subject: [PATCH 27/64] Route all responses through respond() --- src/Router.php | 26 ++++++++++---------- test/ControllerTest.php | 53 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/Router.php b/src/Router.php index ef27112..acb4b0b 100644 --- a/src/Router.php +++ b/src/Router.php @@ -140,21 +140,14 @@ public function route($request, $callback) try { $response = call_user_func_array($callback, $params); if ($response !== NULL) { - if (is_object($response) && self::hasSerializer(get_class($response))) { - self::$serializers[get_class($response)]($response); - } else { - static::respond($response); - } + static::respond($response); } } catch (\Exception $ex) { - if (!empty(self::$serializers[get_class($ex)])) { - self::$serializers[get_class($ex)]($ex); - } else { - if ($ex->getCode() >= 500) { - error_log(get_class($ex) . ' ' . $ex->getMessage() . " @ " . $ex->getTraceAsString()); - } - static::respond(['exception' => get_class($ex), 'message' => $ex->getMessage()], $ex->getCode()); + if ($ex->getCode() >= 500) { + error_log(get_class($ex) . ' ' . $ex->getMessage() . " @ " . $ex->getTraceAsString()); } + + static::respond($ex, $ex->getCode()); } } } @@ -590,6 +583,15 @@ public static function respond($content, $code = 200) header("HTTP/1.1 $code"); + // Keep the default exception response stable while allowing custom + // exception serializers to participate in the normal response pipeline. + if ($content instanceof \Exception && !self::hasSerializer(get_class($content))) { + $content = [ + 'exception' => get_class($content), + 'message' => $content->getMessage(), + ]; + } + if (is_array($content) && !empty($content[0]) && is_object($content[0])) { $obj = $content[0]; } elseif (is_object($content)) { diff --git a/test/ControllerTest.php b/test/ControllerTest.php index d84d2ac..df6e0c6 100644 --- a/test/ControllerTest.php +++ b/test/ControllerTest.php @@ -138,6 +138,59 @@ public function testCustomMethodFallback(): void $this->assertSame('8777', $response_value[0]->price); } + public function testRegisteredSerializerDoesNotBypassRespond(): void + { + global $response_value, $response_code; + + $response = new class { + public string $value = 'test'; + }; + $serializerCalled = false; + + Router::addSerializer(get_class($response), function () use (&$serializerCalled) { + $serializerCalled = true; + return ['serialized' => true]; + }); + + $_SERVER['PATH_INFO'] = '/serialized'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/serialized'; + $_SERVER['REDIRECT_URL'] = '/serialized'; + + $this->app->GET('/serialized', fn () => $response); + + $this->assertSame($response, $response_value); + $this->assertSame(200, $response_code); + $this->assertFalse($serializerCalled); + } + + public function testRegisteredExceptionSerializerDoesNotBypassRespond(): void + { + global $response_value, $response_code; + + $exception = new class('Teapot', 418) extends \Exception { + }; + $serializerCalled = false; + + Router::addSerializer(get_class($exception), function () use (&$serializerCalled) { + $serializerCalled = true; + return ['serialized' => true]; + }); + + $_SERVER['PATH_INFO'] = '/exception'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/exception'; + $_SERVER['REDIRECT_URL'] = '/exception'; + + $this->app->GET('/exception', function () use ($exception) { + throw $exception; + }); + + $this->assertSame($exception, $response_value); + $this->assertSame(418, $response_code); + $this->assertFalse($serializerCalled); + } + public function testAppRun(): void { global $response_value; From db806dc754a8c35ca917bbbcc1ca7bff8bff4e33 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 28 Sep 2026 17:27:46 -0300 Subject: [PATCH 28/64] Cover object controller responses --- test/ControllerTest.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/ControllerTest.php b/test/ControllerTest.php index df6e0c6..478599c 100644 --- a/test/ControllerTest.php +++ b/test/ControllerTest.php @@ -138,6 +138,29 @@ public function testCustomMethodFallback(): void $this->assertSame('8777', $response_value[0]->price); } + public function testControllerObjectResponseBypassesTemplateLookup(): void + { + global $response_value, $response_code; + + $controller = new class { + public function index(): object + { + return (object) ['ok' => true]; + } + }; + + $_SERVER['PATH_INFO'] = '/'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['REDIRECT_URL'] = '/'; + + $this->app->controller('/', $controller); + + $this->assertIsObject($response_value); + $this->assertTrue($response_value->ok); + $this->assertSame(200, $response_code); + } + public function testRegisteredSerializerDoesNotBypassRespond(): void { global $response_value, $response_code; From 24860ba26dc848362debe40e3db6012d15562b25 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 28 Sep 2026 18:24:21 -0300 Subject: [PATCH 29/64] Fix template fallback and default root --- src/Router.php | 12 ++++-- test/TemplateTest.php | 94 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 test/TemplateTest.php diff --git a/src/Router.php b/src/Router.php index acb4b0b..c9231e5 100644 --- a/src/Router.php +++ b/src/Router.php @@ -18,9 +18,15 @@ function __construct(?string $_root = null, private array $config = []) { $this->dice = new \Dice\Dice(); - // By default, set root to the project root (../../.. from vendor/ow/router) + // By default, use the Composer root package (the application root). + // Fall back to ../../../../ from vendor/objectiveweb/router/src. if (!$_root) { - $_root = dirname(dirname(dirname(__DIR__))); + if (class_exists(\Composer\InstalledVersions::class)) { + $rootPackage = \Composer\InstalledVersions::getRootPackage(); + $_root = $rootPackage['install_path'] ?? null; + } + + $_root ??= dirname(dirname(dirname(dirname(__DIR__)))); } $defaults = [ @@ -351,7 +357,7 @@ public function controller($path, $controller) $path != '/' ? $path . '/' : $path ); - $templates = array_unique(["$template_path$fn", "$$template_path$method"]); + $templates = array_unique(["$template_path$fn", "$template_path$method"]); $template = $this->template($templates, $response); diff --git a/test/TemplateTest.php b/test/TemplateTest.php new file mode 100644 index 0000000..614e959 --- /dev/null +++ b/test/TemplateTest.php @@ -0,0 +1,94 @@ +files) as $file) { + if (is_file($file)) { + unlink($file); + } + } + + foreach (array_reverse($this->directories) as $directory) { + if (is_dir($directory)) { + @rmdir($directory); + } + } + + global $response_value, $response_code; + $response_value = null; + $response_code = null; + } + + public function testDefaultTemplateRootUsesComposerProjectRoot(): void + { + $templates = dirname(__DIR__) . '/templates'; + $createdTemplatesDirectory = false; + + if (!is_dir($templates)) { + mkdir($templates, 0777, true); + $this->directories[] = $templates; + $createdTemplatesDirectory = true; + } + + $file = $templates . '/default-root-test.php'; + file_put_contents($file, ''); + $this->files[] = $file; + + $router = new BaseRouter(); + $template = $router->template('default-root-test', ['value' => 'project-root']); + + $this->assertInstanceOf(Template::class, $template); + $this->assertSame('project-root', $template->render()); + } + + public function testControllerFallsBackToHttpMethodTemplate(): void + { + global $response_value, $response_code; + + $root = sys_get_temp_dir() . '/objectiveweb-router-' . bin2hex(random_bytes(8)); + $templates = $root . '/templates'; + + mkdir($templates, 0777, true); + $this->directories[] = $templates; + $this->directories[] = $root; + + $file = $templates . '/get.php'; + file_put_contents($file, ''); + $this->files[] = $file; + + $controller = new class { + public function getFallback(array $query): array + { + return ['value' => 'method-fallback']; + } + }; + + $_GET = []; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['PATH_INFO'] = '/fallback'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/fallback'; + $_SERVER['REDIRECT_URL'] = '/fallback'; + unset($_SERVER['HTTP_ACCEPT']); + + $router = new Router($root); + $router->controller('/', $controller); + + $this->assertInstanceOf(Template::class, $response_value); + $this->assertSame('method-fallback', $response_value->render()); + $this->assertSame(200, $response_code); + } +} From 1c6e3c690af8cccf9f8a1dc70c900d64475fcc8e Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 28 Sep 2026 18:25:05 -0300 Subject: [PATCH 30/64] Avoid reloading shared test router --- test/ControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ControllerTest.php b/test/ControllerTest.php index 478599c..1b7e206 100644 --- a/test/ControllerTest.php +++ b/test/ControllerTest.php @@ -6,7 +6,7 @@ require dirname(__DIR__) . '/example/App/ProductsController.php'; require dirname(__DIR__) . '/example/App/DB/ProductsRepository.php'; require dirname(__DIR__) . '/example/App/Model/Product.php'; -require __DIR__ . '/TestableRouter.php'; +require_once __DIR__ . '/TestableRouter.php'; use App\Model\Product; use App\ProductsController; From 7bfdd67a75c278b4d273529282466af02c327291 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 28 Sep 2026 18:25:07 -0300 Subject: [PATCH 31/64] Avoid reloading shared test router --- test/TemplateTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/TemplateTest.php b/test/TemplateTest.php index 614e959..2a1d1b3 100644 --- a/test/TemplateTest.php +++ b/test/TemplateTest.php @@ -1,7 +1,7 @@ Date: Mon, 28 Sep 2026 18:31:00 -0300 Subject: [PATCH 32/64] Harden middleware execution --- docs/middleware.md | 28 ++++- src/Router.php | 96 ++++++++++++---- src/Router/MiddlewareInterface.php | 15 ++- test/MiddlewareTest.php | 176 +++++++++++++++++++++++++++++ 4 files changed, 282 insertions(+), 33 deletions(-) create mode 100644 test/MiddlewareTest.php diff --git a/docs/middleware.md b/docs/middleware.md index 65dd81c..d027fdf 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -8,7 +8,7 @@ Middleware is implemented as PHP classes that can be applied to controllers or s ## Middleware Class Structure -All middleware classes must implement the `Objectiveweb\Router\MiddlewareInterface`: +Middleware classes may implement `Objectiveweb\Router\MiddlewareInterface` when they provide both hooks. The router invokes `before()` and `after()` only when those methods exist: ```php config['middlewares'] as $mwClass => $mwArgs) { + $middlewareDefinitions[] = [ + 'class' => $mwClass, + 'args' => $mwArgs, + ]; + } + + $classAttributes = $refClass->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF); + $classMiddlewareClasses = []; + foreach ($classAttributes as $attr) { + /** @var Middleware $definition */ + $definition = $attr->newInstance(); + $classMiddlewareClasses[$definition->getClass()] = true; + } - // Start with default set of middlewares (applied to all requests) - // Note: will be overridden by class Attributes if mw class is the same - $middlewares = $this->config['middlewares']; + if ($classMiddlewareClasses) { + $middlewareDefinitions = array_values(array_filter( + $middlewareDefinitions, + static fn (array $definition): bool => !isset($classMiddlewareClasses[$definition['class']]) + )); + } - // Class Middlewares - foreach ($refClass->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF) as $attr) { - /** @var Middleware $mw */ - $mw = $attr->newInstance(); - $middlewares[$mw->getClass()] = $mw->getArgs(); + foreach ($classAttributes as $attr) { + $definition = $attr->newInstance(); + $middlewareDefinitions[] = [ + 'class' => $definition->getClass(), + 'args' => $definition->getArgs(), + ]; } - // Method Middlewares (override class middlewares if they exist with the same class name) - foreach ($refMethod->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF) as $attr) { - $mw = $attr->newInstance(); - unset($middlewares[$mw->getClass()]); // ensure method middleware is inserted after class middlewares - $middlewares[$mw->getClass()] = $mw->getArgs(); + $methodAttributes = $refMethod->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF); + $methodMiddlewareClasses = []; + foreach ($methodAttributes as $attr) { + /** @var Middleware $definition */ + $definition = $attr->newInstance(); + $methodMiddlewareClasses[$definition->getClass()] = true; } - // With the middlewares list, let's instantiate and execute each - foreach ($middlewares as $mw_class => $mw_args) { - // instantiate middleware - $mw = $this->create($mw_class, $mw_args, [get_class($controller) . $mw_class]); + if ($methodMiddlewareClasses) { + $middlewareDefinitions = array_values(array_filter( + $middlewareDefinitions, + static fn (array $definition): bool => !isset($methodMiddlewareClasses[$definition['class']]) + )); + } - $middlewares[$mw_class] = $mw; + foreach ($methodAttributes as $attr) { + $definition = $attr->newInstance(); + $middlewareDefinitions[] = [ + 'class' => $definition->getClass(), + 'args' => $definition->getArgs(), + ]; + } + + // Instantiate and execute before() hooks in declaration order. + $middlewares = []; + foreach ($middlewareDefinitions as $definition) { + $mw = $this->create( + $definition['class'], + $definition['args'], + [get_class($controller) . $definition['class']] + ); - // execute before() middleware functions if (method_exists($mw, 'before')) { - $params = call_user_func([$mw, 'before'], $method, $fn, $params); + $updatedParams = call_user_func([$mw, 'before'], $method, $fn, $params); + if (!is_array($updatedParams)) { + throw new \UnexpectedValueException(sprintf( + '%s::before() must return an array', + get_class($mw) + ), 500); + } + + $params = $updatedParams; } + + $middlewares[] = $mw; } // we're testing this here in case Middlewares end the request prematurely @@ -333,9 +381,11 @@ public function controller($path, $controller) $response = call_user_func_array([$controller, $fn], $params); - // execute after() middleware functions in reverse order + // Execute implemented after() hooks in reverse order. foreach (array_reverse($middlewares) as $mw) { - $response = call_user_func([$mw, 'after'], $method, $fn, $params, $response); + if (method_exists($mw, 'after')) { + $response = call_user_func([$mw, 'after'], $method, $fn, $params, $response); + } } // Templates receive arrays as their data context. Other response types diff --git a/src/Router/MiddlewareInterface.php b/src/Router/MiddlewareInterface.php index 1dfc236..7a512a3 100644 --- a/src/Router/MiddlewareInterface.php +++ b/src/Router/MiddlewareInterface.php @@ -1,21 +1,26 @@ name; + + return $params; + } + + public function after(string $method, string $fn, array $params, mixed $response): mixed + { + self::$events[] = 'after:' . $this->name; + + return $response; + } +} + +class BeforeOnlyMiddleware +{ + public static int $calls = 0; + + public function before(string $method, string $fn, array $params): array + { + self::$calls++; + + return $params; + } +} + +class InvalidBeforeMiddleware +{ + public function before(string $method, string $fn, array $params): string + { + return 'invalid'; + } +} + +#[Middleware(RecordingMiddleware::class, 'class-first')] +#[Middleware(RecordingMiddleware::class, 'class-second')] +class RepeatedMiddlewareController +{ + public function index(array $query): string + { + RecordingMiddleware::$events[] = 'controller'; + + return 'ok'; + } +} + +#[Middleware(RecordingMiddleware::class, 'class')] +class MethodOverrideMiddlewareController +{ + #[Middleware(RecordingMiddleware::class, 'method-first')] + #[Middleware(RecordingMiddleware::class, 'method-second')] + public function index(array $query): string + { + RecordingMiddleware::$events[] = 'controller'; + + return 'ok'; + } +} + +#[Middleware(BeforeOnlyMiddleware::class)] +class BeforeOnlyMiddlewareController +{ + public function index(array $query): string + { + return 'ok'; + } +} + +#[Middleware(InvalidBeforeMiddleware::class)] +class InvalidBeforeMiddlewareController +{ + public function index(array $query): string + { + return 'unreachable'; + } +} + +class MiddlewareTest extends TestCase +{ + private Router $router; + + protected function setUp(): void + { + $this->router = new Router(); + + RecordingMiddleware::$events = []; + BeforeOnlyMiddleware::$calls = 0; + + $_GET = []; + $_POST = []; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['PATH_INFO'] = '/'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['REDIRECT_URL'] = '/'; + unset($_SERVER['HTTP_ACCEPT']); + + global $response_value, $response_code; + $response_value = null; + $response_code = null; + } + + public function testRepeatedMiddlewareOfSameClassIsPreserved(): void + { + global $response_value; + + $this->router->controller('/', RepeatedMiddlewareController::class); + + $this->assertSame('ok', $response_value); + $this->assertSame([ + 'before:class-first', + 'before:class-second', + 'controller', + 'after:class-second', + 'after:class-first', + ], RecordingMiddleware::$events); + } + + public function testMethodMiddlewareOverridesClassMiddlewareOfSameClassAndPreservesRepeats(): void + { + global $response_value; + + $this->router->controller('/', MethodOverrideMiddlewareController::class); + + $this->assertSame('ok', $response_value); + $this->assertSame([ + 'before:method-first', + 'before:method-second', + 'controller', + 'after:method-second', + 'after:method-first', + ], RecordingMiddleware::$events); + } + + public function testMiddlewareWithoutAfterHookDoesNotCrash(): void + { + global $response_value; + + $this->router->controller('/', BeforeOnlyMiddlewareController::class); + + $this->assertSame('ok', $response_value); + $this->assertSame(1, BeforeOnlyMiddleware::$calls); + } + + public function testBeforeHookMustReturnArray(): void + { + global $response_value, $response_code; + + $this->router->controller('/', InvalidBeforeMiddlewareController::class); + + $this->assertInstanceOf(\UnexpectedValueException::class, $response_value); + $this->assertSame( + InvalidBeforeMiddleware::class . '::before() must return an array', + $response_value->getMessage() + ); + $this->assertSame(500, $response_code); + } +} From d57c4c9f3fafb46ba790a57bd63c9da875843b90 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 28 Sep 2026 18:43:11 -0300 Subject: [PATCH 33/64] Prepare v3 release metadata and verification --- .github/workflows/release.yml | 62 ++++++++++++++++ CHANGELOG.md | 130 ++++++++++++++++++++++++++++++++++ LICENSE | 21 ++++++ README.md | 19 ++--- 4 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..79346b7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release verification + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + test: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: + - '8.1' + - '8.2' + - '8.3' + - '8.4' + - '8.5' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Validate Composer metadata + run: composer validate --strict + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run tests + run: composer test + + production-install: + name: Production install + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + tools: composer:v2 + + - name: Validate Composer metadata + run: composer validate --strict + + - name: Verify production dependencies + run: composer install --no-dev --prefer-dist --no-interaction --no-progress diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b3478bf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,130 @@ +# Changelog + +All notable changes to Objectiveweb Router are documented in this file. + +## [3.0.0] - Unreleased + +### Breaking changes + +- Require PHP 8.1 or newer. +- Router now composes Dice instead of extending it. Dependency injection remains available through `addRule()` and `create()`, but inherited Dice methods are no longer part of the Router API. +- Replace controller `before()` / `beforePost()` style hooks with attribute-based middleware. +- Middleware `before()` hooks must return the complete controller argument array. +- Method-level middleware replaces broader middleware of the same class; repeated middleware at the same scope is preserved and executed in declaration order. +- Responses are routed through `respond()`, including objects with registered serializers and exceptions. +- Controller object responses bypass template lookup unless they are arrays intended as template data. +- The default template directory is now resolved from the Composer application root as `/templates`. +- The supported PHPUnit baseline is PHPUnit 10+. + +### Added + +- GitHub Actions CI for PHP 8.1 through 8.5. +- Dedicated release verification workflow for version tags. +- Repeatable middleware execution with reverse-order `after()` unwinding. +- Regression coverage for controller object responses, middleware ordering, template fallback, template root resolution, serializer response dispatch, and current controller routing behavior. +- MIT license. + +### Changed + +- Pin Objectiveweb Dice to the stable `^4.0.4` series. +- Update JMS Serializer development compatibility to `^3.32`. +- Example JMS metadata now uses PHP attributes. +- Non-string response bodies are JSON-encoded before output. +- Route matching for the root controller now handles nested paths correctly. +- Composer metadata is validated with `composer validate --strict` in CI and release verification. + +### Fixed + +- HTTP-method template fallback no longer uses an accidental variable-variable expression. +- Controller responses that are not arrays no longer reach the array-only template renderer. +- Overridden `respond()` methods work again through late static binding. +- Middleware without an `after()` method no longer crashes response processing. + +## Migrating from 2.x + +Version 3 is intentionally breaking and does not provide a compatibility layer for 2.x applications. + +### PHP + +Update the runtime to PHP 8.1 or newer: + +```json +{ + "require": { + "php": ">=8.1", + "objectiveweb/router": "^3.0" + } +} +``` + +### Dependency injection + +Router no longer extends `Dice\Dice`. + +Continue using the Router-level DI methods: + +```php +$router->addRule(Service::class, [ + 'shared' => true, +]); + +$service = $router->create(Service::class); +``` + +Code that called other inherited Dice methods on the Router should create/configure dependencies through the supported Router API instead. + +### Controller hooks and middleware + +Controller methods such as `before()` and `beforePost()` are no longer invoked automatically. Move request/response interception to middleware attributes: + +```php +use Objectiveweb\Router\Middleware; + +#[Middleware(AuthenticationMiddleware::class)] +class Controller +{ + #[Middleware(AuditMiddleware::class)] + public function index(array $query) + { + // ... + } +} +``` + +A middleware `before()` method must return the complete controller argument array: + +```php +public function before(string $method, string $fn, array $params): array +{ + return $params; +} +``` + +`after()` is optional. When present, it receives the controller response and may transform it. After hooks execute in reverse middleware order. + +### Responses and serializers + +All routed responses now enter the common `respond()` pipeline. Custom subclasses overriding `respond()` therefore see normal responses, registered-serializer responses, and exceptions. + +A renderable object may return either a completed string body or a structured non-string value. Non-string values are JSON-encoded by the router. + +### Templates + +The default template directory is now: + +```text +/templates +``` + +Passing an explicit Router root continues to override this default. + +Controller templates receive array responses as their data context. Other response types bypass template lookup and continue through the response pipeline. + +### Tests and development + +The development test suite supports PHP 8.1-8.5 and PHPUnit 10-12. Run: + +```bash +composer validate --strict +composer test +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..71a2c27 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Guilherme Barile + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9bf5303..4f8dc19 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ Lightweight url router with dependency injection support. -## Instalation +## Installation Add the dependency to `composer.json`, then `composer install` { "require": { - "objectiveweb/router": "~2.0" + "objectiveweb/router": "^3.0" } } @@ -87,16 +87,6 @@ In this case, the request is mapped to the corresponding class method as follows } - // Runs before all actions - function before() { - - } - - // Runs before post(); - function beforePost($body) { - - } - // POST / function post($body) { @@ -129,6 +119,8 @@ last argument Other request methods are also valid (i.e. HEAD, OPTIONS, etc), check the example subdir for other uses. +Request/response interception is handled by attribute-based middleware. See [middleware documentation](docs/middleware.md). + ### Automatic routing You can bootstrap the application on a particular namespace using @@ -143,8 +135,7 @@ for a working demo. ## Dependency Injection -Since version 2.0, the Router extends [Dice](https://r.je/dice.html), which provides a dependency injection -container to the application. +Router uses [Dice](https://r.je/dice.html) internally for dependency injection and exposes `addRule()` and `create()` as its supported container API. Date: Mon, 28 Sep 2026 19:15:50 -0300 Subject: [PATCH 34/64] Parse request bodies by Content-Type --- CHANGELOG.md | 1 + src/Router.php | 58 +++++++++++++------- test/RequestBodyTest.php | 111 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 19 deletions(-) create mode 100644 test/RequestBodyTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b3478bf..7edde4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ All notable changes to Objectiveweb Router are documented in this file. ### Fixed +- Request body parsing now follows `Content-Type`: JSON (including `+json` media types), URL-encoded forms, multipart forms, and raw/unknown bodies are handled explicitly. - HTTP-method template fallback no longer uses an accidental variable-variable expression. - Controller responses that are not arrays no longer reach the array-only template renderer. - Overridden `respond()` methods work again through late static binding. diff --git a/src/Router.php b/src/Router.php index 4d5de13..11f5b7d 100644 --- a/src/Router.php +++ b/src/Router.php @@ -584,27 +584,47 @@ public static function url($str = null) public static function parse_post_body($decoded = true, $as_array = true) { + $contentType = strtolower(trim(explode( + ';', + $_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? '' + )[0])); + + // $_POST is normally an array populated by PHP for form requests. + // Keeping string support is useful for tests and callers that inject a raw body. + $postBody = is_string($_POST) + ? $_POST + : file_get_contents('php://input'); + + if (!$decoded) { + return $postBody; + } - switch ($_SERVER['REQUEST_METHOD']) { - case 'POST': - case 'PUT': - case 'PATCH': - if (!empty($_POST)) { - return is_string($_POST) && $decoded ? json_decode($_POST, $as_array) : $_POST; - } - default: - $post_body = file_get_contents('php://input'); - if (strlen($post_body) > 0 && $decoded) { - if ($post_body[0] == '{' || $post_body[0] == '[') { - return json_decode($post_body, $as_array); - } else { - parse_str($post_body, $return); - return $return; - } - } else { - return $post_body; - } + if ($contentType === 'application/json' || str_ends_with($contentType, '+json')) { + return json_decode($postBody, $as_array); } + + if ($contentType === 'application/x-www-form-urlencoded') { + if (is_array($_POST) && !empty($_POST)) { + return $_POST; + } + + parse_str($postBody, $data); + + return $data; + } + + if ($contentType === 'multipart/form-data') { + return is_array($_POST) ? $_POST : []; + } + + // When Content-Type is missing or unsupported, do not guess from the + // body contents. Return PHP-parsed form data when available, otherwise + // preserve the raw body. + if (is_array($_POST) && !empty($_POST)) { + return $_POST; + } + + return $postBody; } /** diff --git a/test/RequestBodyTest.php b/test/RequestBodyTest.php new file mode 100644 index 0000000..d5f3e39 --- /dev/null +++ b/test/RequestBodyTest.php @@ -0,0 +1,111 @@ +assertTrue(Router::parse_post_body()); + + $_POST = '123'; + $this->assertSame(123, Router::parse_post_body()); + + $_POST = '"hello"'; + $this->assertSame('hello', Router::parse_post_body()); + + $_POST = 'null'; + $this->assertNull(Router::parse_post_body()); + } + + public function testStructuredJsonMediaTypeIsDecoded(): void + { + $_SERVER['CONTENT_TYPE'] = 'application/problem+json'; + $_POST = '{"title":"Invalid request"}'; + + $this->assertSame( + ['title' => 'Invalid request'], + Router::parse_post_body() + ); + } + + public function testJsonCanBeDecodedAsObject(): void + { + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_POST = '{"ok":true}'; + + $body = Router::parse_post_body(true, false); + + $this->assertInstanceOf(stdClass::class, $body); + $this->assertTrue($body->ok); + } + + public function testUrlEncodedBodyIsParsed(): void + { + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $_POST = 'name=Router&enabled=1'; + + $this->assertSame( + ['name' => 'Router', 'enabled' => '1'], + Router::parse_post_body() + ); + } + + public function testPhpParsedFormDataIsReturnedDirectly(): void + { + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $_POST = ['name' => 'Router']; + + $this->assertSame(['name' => 'Router'], Router::parse_post_body()); + } + + public function testMultipartBodyUsesPhpPostData(): void + { + $_SERVER['CONTENT_TYPE'] = 'multipart/form-data'; + $_POST = ['name' => 'Router']; + + $this->assertSame(['name' => 'Router'], Router::parse_post_body()); + } + + public function testUnknownContentTypeRemainsRaw(): void + { + $_SERVER['CONTENT_TYPE'] = 'text/plain'; + $_POST = 'name=Router&enabled=1'; + + $this->assertSame( + 'name=Router&enabled=1', + Router::parse_post_body() + ); + } + + public function testMissingContentTypeDoesNotGuessJson(): void + { + $_POST = '{"ok":true}'; + + $this->assertSame('{"ok":true}', Router::parse_post_body()); + } + + public function testDecodedFalseAlwaysReturnsRawBody(): void + { + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_POST = '{"ok":true}'; + + $this->assertSame( + '{"ok":true}', + Router::parse_post_body(false) + ); + } +} From 232f3d4d8983e25ef24ca0b25f0ecc7a43d1d6d0 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 28 Sep 2026 19:16:35 -0300 Subject: [PATCH 35/64] Declare JSON content type in controller tests --- test/ControllerTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/ControllerTest.php b/test/ControllerTest.php index 1b7e206..f2a7769 100644 --- a/test/ControllerTest.php +++ b/test/ControllerTest.php @@ -82,6 +82,7 @@ public function testPost(): void $_POST = '{ "sku" : 10, "name" : "Test Product", "price" : 89.99 }'; $_SERVER['PATH_INFO'] = '/'; $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; $_SERVER['REQUEST_URI'] = '/'; $_SERVER['REDIRECT_URL'] = '/'; @@ -102,6 +103,7 @@ public function testPut(): void $_POST = '{ "name" : "Test Rename", "price" : 89.99 }'; $_SERVER['PATH_INFO'] = '/2'; $_SERVER['REQUEST_METHOD'] = 'PUT'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; $_SERVER['REQUEST_URI'] = '/2'; $_SERVER['REDIRECT_URL'] = '/2'; From cad25df00b86a75e41c56dd22267d17c4d50f4d0 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 28 Sep 2026 19:26:06 -0300 Subject: [PATCH 36/64] Complete response content negotiation --- CHANGELOG.md | 3 + src/Router.php | 219 +++++++++++++++++++++++++++---- test/ResponseNegotiationTest.php | 124 +++++++++++++++++ test/TemplateTest.php | 84 ++++++++++++ test/TestableRouter.php | 6 +- 5 files changed, 407 insertions(+), 29 deletions(-) create mode 100644 test/ResponseNegotiationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7edde4f..bea33d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ All notable changes to Objectiveweb Router are documented in this file. ### Changed +- Response negotiation now honors `Accept` media ranges, q-values, wildcards, and q=0 exclusions; HTML/JSON responses use explicit content types and unsupported requests receive 406. - Pin Objectiveweb Dice to the stable `^4.0.4` series. - Update JMS Serializer development compatibility to `^3.32`. - Example JMS metadata now uses PHP attributes. @@ -105,6 +106,8 @@ public function before(string $method, string $fn, array $params): array ### Responses and serializers +Response representation is now negotiated from the request `Accept` header. Controller array results with a matching template can be rendered as `text/html` or returned as `application/json`; missing `Accept` behaves like `*/*` and prefers HTML when a template is available. Requests that reject all available representations receive HTTP 406. + All routed responses now enter the common `respond()` pipeline. Custom subclasses overriding `respond()` therefore see normal responses, registered-serializer responses, and exceptions. A renderable object may return either a completed string body or a structured non-string value. Non-string values are JSON-encoded by the router. diff --git a/src/Router.php b/src/Router.php index 11f5b7d..cc13217 100644 --- a/src/Router.php +++ b/src/Router.php @@ -390,10 +390,7 @@ public function controller($path, $controller) // Templates receive arrays as their data context. Other response types // are already complete response values and should be handled by respond(). - if ( - !is_array($response) - || (!empty($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'json') !== false) - ) { + if (!is_array($response)) { return $response; } @@ -408,11 +405,23 @@ public function controller($path, $controller) ); $templates = array_unique(["$template_path$fn", "$template_path$method"]); - $template = $this->template($templates, $response); - // in case no template is available, return the raw response - return $template ?? $response; + if (!$template) { + return $response; + } + + // A controller array with a matching template has both HTML and + // JSON representations. Missing Accept behaves like */* and HTML + // wins ties for browser/controller routes. + header('Vary: Accept', false); + + return static::negotiateContentType( + ['text/html', 'application/json'], + $_SERVER['HTTP_ACCEPT'] ?? null + ) === 'text/html' + ? $template + : $response; }); } @@ -654,13 +663,90 @@ public static function redirect($to, $code = 301) exit(); } - public static function respond($content, $code = 200) + /** + * Choose the best representation from the server-supported content types. + * + * Missing Accept behaves like */*. More specific media ranges override + * wildcards, including q=0 exclusions. Ties are resolved by the order of + * $available so callers can express a server preference. + */ + public static function negotiateContentType(array $available, ?string $accept = null): ?string { + $accept = trim((string) $accept); + if ($accept === '') { + $accept = '*/*'; + } - header("HTTP/1.1 $code"); + $ranges = []; + foreach (explode(',', $accept) as $entry) { + $parts = array_map('trim', explode(';', $entry)); + $mediaRange = strtolower((string) array_shift($parts)); - // Keep the default exception response stable while allowing custom - // exception serializers to participate in the normal response pipeline. + if (!str_contains($mediaRange, '/')) { + continue; + } + + $quality = 1.0; + foreach ($parts as $parameter) { + if (preg_match('/^q\\s*=\\s*([0-9.]+)$/i', $parameter, $match)) { + $quality = max(0.0, min(1.0, (float) $match[1])); + } + } + + [$type, $subtype] = explode('/', $mediaRange, 2); + $ranges[] = [ + 'type' => $type, + 'subtype' => $subtype, + 'q' => $quality, + ]; + } + + $selected = null; + $selectedQuality = -1.0; + + foreach ($available as $contentType) { + [$type, $subtype] = explode('/', strtolower($contentType), 2); + + $bestSpecificity = -1; + $quality = 0.0; + + foreach ($ranges as $range) { + if ($range['type'] !== '*' && $range['type'] !== $type) { + continue; + } + + if ($range['subtype'] !== '*' && $range['subtype'] !== $subtype) { + continue; + } + + $specificity = $range['type'] === '*' + ? 0 + : ($range['subtype'] === '*' ? 1 : 2); + + if ($specificity > $bestSpecificity) { + $bestSpecificity = $specificity; + $quality = $range['q']; + } elseif ($specificity === $bestSpecificity) { + $quality = max($quality, $range['q']); + } + } + + if ($bestSpecificity >= 0 && $quality > 0 && $quality > $selectedQuality) { + $selected = $contentType; + $selectedQuality = $quality; + } + } + + return $selected; + } + + /** + * Prepare a response body without emitting headers or terminating execution. + * + * @return array{body:string, content_type:?string, vary_accept:bool} + */ + protected static function prepareResponse($content, ?string $accept = null): array + { if ($content instanceof \Exception && !self::hasSerializer(get_class($content))) { $content = [ 'exception' => get_class($content), @@ -668,36 +754,113 @@ public static function respond($content, $code = 200) ]; } + $obj = null; if (is_array($content) && !empty($content[0]) && is_object($content[0])) { $obj = $content[0]; } elseif (is_object($content)) { $obj = $content; } - // serialize the response if necessary - if (!empty($obj)) { - if (is_callable([$obj, 'render'])) { - // TODO passar content_type se tiver a header accept - $content = call_user_func([$obj, 'render']); - } elseif (!empty(self::$serializers[get_class($obj)])) { - $content = self::$serializers[get_class($obj)]($content); - } elseif (class_exists('\JMS\Serializer\SerializerBuilder')) { - $serializer = \JMS\Serializer\SerializerBuilder::create()->build(); - $content = $serializer->serialize($content, 'json', \JMS\Serializer\SerializationContext::create()->enableMaxDepthChecks()); - } else { - $content = json_encode($content); + $isTemplate = $content instanceof Template; + $isRenderable = is_object($content) && is_callable([$content, 'render']); + + if ($isTemplate) { + $available = ['text/html']; + } elseif (is_string($content) || $isRenderable) { + $available = ['text/html', 'application/json']; + } else { + $available = ['application/json']; + } + + $contentType = static::negotiateContentType($available, $accept); + $varyAccept = count($available) > 1; + + if ($contentType === null) { + return [ + 'body' => '', + 'content_type' => null, + 'vary_accept' => $varyAccept, + ]; + } + + if ($contentType === 'text/html') { + $body = $isRenderable ? call_user_func([$content, 'render']) : $content; + + // A render() method may return structured data. That result is JSON, + // not HTML, and must itself be acceptable to the client. + if (!is_string($body)) { + if (static::negotiateContentType(['application/json'], $accept) === null) { + return [ + 'body' => '', + 'content_type' => null, + 'vary_accept' => true, + ]; + } + + return [ + 'body' => static::serializeJson($body), + 'content_type' => 'application/json', + 'vary_accept' => true, + ]; } + + return [ + 'body' => $body, + 'content_type' => 'text/html', + 'vary_accept' => $varyAccept, + ]; + } + + return [ + 'body' => static::serializeJson($content, $obj), + 'content_type' => 'application/json', + 'vary_accept' => $varyAccept, + ]; + } + + private static function serializeJson($content, ?object $obj = null): string + { + if ($obj && self::hasSerializer(get_class($obj))) { + $content = self::$serializers[get_class($obj)]($content); + + return is_string($content) + ? $content + : json_encode($content, JSON_THROW_ON_ERROR); + } + + if ($obj && class_exists('\\JMS\\Serializer\\SerializerBuilder')) { + $serializer = \\JMS\\Serializer\\SerializerBuilder::create()->build(); + + return $serializer->serialize( + $content, + 'json', + \\JMS\\Serializer\\SerializationContext::create()->enableMaxDepthChecks() + ); } - if (!is_string($content)) { - $content = json_encode($content); + return json_encode($content, JSON_THROW_ON_ERROR); + } + + public static function respond($content, $code = 200) + { + $response = static::prepareResponse( + $content, + $_SERVER['HTTP_ACCEPT'] ?? null + ); + + if ($response['vary_accept']) { + header('Vary: Accept', false); } - if (!empty($content) && is_string($content) && ($content[0] == '{' || $content[0] == '[')) { - header('Content-type: application/json'); + if ($response['content_type'] === null) { + header('HTTP/1.1 406'); + exit(''); } - exit($content); + header("HTTP/1.1 $code"); + header('Content-Type: ' . $response['content_type'] . '; charset=utf-8'); + + exit($response['body']); } public static function isAjax() diff --git a/test/ResponseNegotiationTest.php b/test/ResponseNegotiationTest.php new file mode 100644 index 0000000..3cf102e --- /dev/null +++ b/test/ResponseNegotiationTest.php @@ -0,0 +1,124 @@ +assertSame( + 'text/html', + Router::negotiateContentType(['text/html', 'application/json']) + ); + } + + public function testQualityValuesAreHonored(): void + { + $this->assertSame( + 'application/json', + Router::negotiateContentType( + ['text/html', 'application/json'], + 'text/html;q=0.4, application/json;q=0.9' + ) + ); + } + + public function testSpecificExclusionOverridesWildcard(): void + { + $this->assertSame( + 'application/json', + Router::negotiateContentType( + ['text/html', 'application/json'], + 'text/html;q=0, */*;q=1' + ) + ); + } + + public function testUnsupportedAcceptReturnsNull(): void + { + $this->assertNull( + Router::negotiateContentType( + ['text/html', 'application/json'], + 'image/png' + ) + ); + } + + public function testJsonStringIsEncodedAsJsonScalar(): void + { + $response = Router::prepareResponseForTest('hello', 'application/json'); + + $this->assertSame('application/json', $response['content_type']); + $this->assertSame('"hello"', $response['body']); + $this->assertTrue($response['vary_accept']); + } + + public function testHtmlStringRemainsRaw(): void + { + $response = Router::prepareResponseForTest('Hello', 'text/html'); + + $this->assertSame('text/html', $response['content_type']); + $this->assertSame('Hello', $response['body']); + } + + public function testStructuredResponseRejectsHtmlOnlyRequest(): void + { + $response = Router::prepareResponseForTest(['ok' => true], 'text/html'); + + $this->assertNull($response['content_type']); + $this->assertSame('', $response['body']); + } + + public function testRenderableObjectCanProduceHtmlOrJson(): void + { + $content = new class { + public string $value = 'test'; + + public function render(): string + { + return '

test

'; + } + }; + + $html = Router::prepareResponseForTest($content, 'text/html'); + $this->assertSame('text/html', $html['content_type']); + $this->assertSame('

test

', $html['body']); + + $json = Router::prepareResponseForTest($content, 'application/json'); + $this->assertSame('application/json', $json['content_type']); + $this->assertStringContainsString('"value":"test"', $json['body']); + } + + public function testStructuredRenderResultFallsBackToJsonWhenAccepted(): void + { + $content = new class { + public function render(): array + { + return ['ok' => true]; + } + }; + + $response = Router::prepareResponseForTest($content, '*/*'); + + $this->assertSame('application/json', $response['content_type']); + $this->assertSame('{"ok":true}', $response['body']); + } + + public function testStructuredRenderResultIsNotAcceptableForHtmlOnly(): void + { + $content = new class { + public function render(): array + { + return ['ok' => true]; + } + }; + + $response = Router::prepareResponseForTest($content, 'text/html'); + + $this->assertNull($response['content_type']); + } +} diff --git a/test/TemplateTest.php b/test/TemplateTest.php index 2a1d1b3..eca5419 100644 --- a/test/TemplateTest.php +++ b/test/TemplateTest.php @@ -54,6 +54,90 @@ public function testDefaultTemplateRootUsesComposerProjectRoot(): void $this->assertSame('project-root', $template->render()); } + public function testMissingAcceptPrefersHtmlTemplate(): void + { + global $response_value; + + [$router, $controller] = $this->createNegotiatedTemplateRoute(); + unset($_SERVER['HTTP_ACCEPT']); + + $router->controller('/', $controller); + + $this->assertInstanceOf(Template::class, $response_value); + } + + public function testWildcardAcceptPrefersHtmlTemplate(): void + { + global $response_value; + + [$router, $controller] = $this->createNegotiatedTemplateRoute(); + $_SERVER['HTTP_ACCEPT'] = '*/*'; + + $router->controller('/', $controller); + + $this->assertInstanceOf(Template::class, $response_value); + } + + public function testJsonAcceptBypassesTemplate(): void + { + global $response_value; + + [$router, $controller] = $this->createNegotiatedTemplateRoute(); + $_SERVER['HTTP_ACCEPT'] = 'application/json'; + + $router->controller('/', $controller); + + $this->assertSame(['value' => 'negotiated'], $response_value); + } + + public function testAcceptQualityChoosesPreferredRepresentation(): void + { + global $response_value, $response_code; + + [$router, $controller] = $this->createNegotiatedTemplateRoute(); + + $_SERVER['HTTP_ACCEPT'] = 'text/html;q=0.5, application/json;q=0.9'; + $router->controller('/', $controller); + $this->assertSame(['value' => 'negotiated'], $response_value); + + $response_value = null; + $response_code = null; + + $_SERVER['HTTP_ACCEPT'] = 'text/html;q=0.9, application/json;q=0.5'; + $router->controller('/', $controller); + $this->assertInstanceOf(Template::class, $response_value); + } + + private function createNegotiatedTemplateRoute(): array + { + $root = sys_get_temp_dir() . '/objectiveweb-router-negotiation-' . bin2hex(random_bytes(8)); + $templates = $root . '/templates'; + + mkdir($templates, 0777, true); + $this->directories[] = $templates; + $this->directories[] = $root; + + $file = $templates . '/index.php'; + file_put_contents($file, ''); + $this->files[] = $file; + + $controller = new class { + public function index(array $query): array + { + return ['value' => 'negotiated']; + } + }; + + $_GET = []; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['PATH_INFO'] = '/'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['REDIRECT_URL'] = '/'; + + return [new Router($root), $controller]; + } + public function testControllerFallsBackToHttpMethodTemplate(): void { global $response_value, $response_code; diff --git a/test/TestableRouter.php b/test/TestableRouter.php index 88692d9..9016890 100644 --- a/test/TestableRouter.php +++ b/test/TestableRouter.php @@ -12,4 +12,8 @@ static function respond($value, $code = 200) { $response_value = $value; $response_code = $code; } -} \ No newline at end of file + + public static function prepareResponseForTest($content, ?string $accept = null): array { + return parent::prepareResponse($content, $accept); + } +} From 1db39cbf85a1e2ee93001d679997ee565506cd2c Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 28 Sep 2026 19:26:30 -0300 Subject: [PATCH 37/64] Fix negotiation docblock syntax --- src/Router.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Router.php b/src/Router.php index cc13217..039e840 100644 --- a/src/Router.php +++ b/src/Router.php @@ -666,7 +666,7 @@ public static function redirect($to, $code = 301) /** * Choose the best representation from the server-supported content types. * - * Missing Accept behaves like */*. More specific media ranges override + * Missing Accept behaves like the wildcard media range. More specific ranges override * wildcards, including q=0 exclusions. Ties are resolved by the order of * $available so callers can express a server preference. */ From 591ecfadfddb2dfee6b405140835253a581443d1 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 28 Sep 2026 19:27:17 -0300 Subject: [PATCH 38/64] Fix serializer namespace escaping --- src/Router.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Router.php b/src/Router.php index 039e840..4fe2f82 100644 --- a/src/Router.php +++ b/src/Router.php @@ -829,12 +829,12 @@ private static function serializeJson($content, ?object $obj = null): string } if ($obj && class_exists('\\JMS\\Serializer\\SerializerBuilder')) { - $serializer = \\JMS\\Serializer\\SerializerBuilder::create()->build(); + $serializer = \JMS\Serializer\SerializerBuilder::create()->build(); return $serializer->serialize( $content, 'json', - \\JMS\\Serializer\\SerializationContext::create()->enableMaxDepthChecks() + \JMS\Serializer\SerializationContext::create()->enableMaxDepthChecks() ); } From a710e3d2768d146d5e5d496a9e7a7beba4babc17 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 29 Sep 2026 09:26:52 -0300 Subject: [PATCH 39/64] Catch all throwables at route boundary --- CHANGELOG.md | 1 + src/Router.php | 54 +++++++++------- test/ErrorBoundaryTest.php | 123 +++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 21 deletions(-) create mode 100644 test/ErrorBoundaryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index bea33d9..1c73dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ All notable changes to Objectiveweb Router are documented in this file. ### Fixed +- Route execution now catches all PHP `Throwable` failures, including `TypeError`/`Error`, normalizes invalid exception codes to HTTP 500, and includes dependency-injection/callback resolution inside the HTTP error boundary. - Request body parsing now follows `Content-Type`: JSON (including `+json` media types), URL-encoded forms, multipart forms, and raw/unknown bodies are handled explicitly. - HTTP-method template fallback no longer uses an accidental variable-variable expression. - Controller responses that are not arrays no longer reach the array-only template renderer. diff --git a/src/Router.php b/src/Router.php index 4fe2f82..6d09d3d 100644 --- a/src/Router.php +++ b/src/Router.php @@ -124,36 +124,48 @@ public function route($request, $callback) array_shift($params); - // route expects a callable - // this can be: - // - a function() - // - [ $instance, 'method' ] - // - [ 'Classname', 'method' ] - - // For the third case, we need to instantiate the class - if (is_array($callback) && is_string($callback[0])) { - $callback[0] = $this->create($callback[0], $params); - } + // Route execution is the HTTP error boundary. Callback resolution, + // dependency injection, invocation and response preparation can all + // raise PHP 8 Errors as well as Exceptions, so catch Throwable. + try { + // route expects a callable + // this can be: + // - a function() + // - [ $instance, 'method' ] + // - [ 'Classname', 'method' ] + + // For the third case, instantiate the class through Dice. + if (is_array($callback) && is_string($callback[0])) { + $callback[0] = $this->create($callback[0], $params); + } - if (!is_callable($callback)) { - throw new \Exception(sprintf(_('%s: Invalid callback'), $callback), 500); - } + if (!is_callable($callback)) { + $callbackType = is_string($callback) ? $callback : get_debug_type($callback); + throw new \RuntimeException( + sprintf(_('%s: Invalid callback'), $callbackType), + 500 + ); + } - if (func_num_args() > 2) { - $params = array_merge($params, array_slice(func_get_args(), 2)); - } + if (func_num_args() > 2) { + $params = array_merge($params, array_slice(func_get_args(), 2)); + } - try { $response = call_user_func_array($callback, $params); if ($response !== NULL) { static::respond($response); } - } catch (\Exception $ex) { - if ($ex->getCode() >= 500) { + } catch (\Throwable $ex) { + $status = (int) $ex->getCode(); + if ($status < 400 || $status > 599) { + $status = 500; + } + + if ($status >= 500) { error_log(get_class($ex) . ' ' . $ex->getMessage() . " @ " . $ex->getTraceAsString()); } - static::respond($ex, $ex->getCode()); + static::respond($ex, $status); } } } @@ -747,7 +759,7 @@ public static function negotiateContentType(array $available, ?string $accept = */ protected static function prepareResponse($content, ?string $accept = null): array { - if ($content instanceof \Exception && !self::hasSerializer(get_class($content))) { + if ($content instanceof \Throwable && !self::hasSerializer(get_class($content))) { $content = [ 'exception' => get_class($content), 'message' => $content->getMessage(), diff --git a/test/ErrorBoundaryTest.php b/test/ErrorBoundaryTest.php new file mode 100644 index 0000000..ee1358e --- /dev/null +++ b/test/ErrorBoundaryTest.php @@ -0,0 +1,123 @@ +router = new Router(); + + $_GET = []; + $_POST = []; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['PATH_INFO'] = '/'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['REDIRECT_URL'] = '/'; + unset($_SERVER['HTTP_ACCEPT']); + + global $response_value, $response_code; + $response_value = null; + $response_code = null; + } + + public function testTypeErrorIsCapturedAsInternalServerError(): void + { + global $response_value, $response_code; + + $_SERVER['PATH_INFO'] = '/type-error'; + $_SERVER['REQUEST_URI'] = '/type-error'; + $_SERVER['REDIRECT_URL'] = '/type-error'; + + $this->router->GET('/type-error', function () { + strlen([]); + }); + + $this->assertInstanceOf(\TypeError::class, $response_value); + $this->assertSame(500, $response_code); + } + + public function testExceptionCodeZeroIsNormalizedTo500(): void + { + global $response_value, $response_code; + + $_SERVER['PATH_INFO'] = '/zero'; + $_SERVER['REQUEST_URI'] = '/zero'; + $_SERVER['REDIRECT_URL'] = '/zero'; + + $this->router->GET('/zero', function () { + throw new \RuntimeException('Failure'); + }); + + $this->assertInstanceOf(\RuntimeException::class, $response_value); + $this->assertSame(500, $response_code); + } + + public function testValidHttpExceptionCodeIsPreserved(): void + { + global $response_value, $response_code; + + $_SERVER['PATH_INFO'] = '/missing'; + $_SERVER['REQUEST_URI'] = '/missing'; + $_SERVER['REDIRECT_URL'] = '/missing'; + + $this->router->GET('/missing', function () { + throw new \RuntimeException('Missing', 404); + }); + + $this->assertInstanceOf(\RuntimeException::class, $response_value); + $this->assertSame(404, $response_code); + } + + public function testDependencyInjectionFailureIsInsideBoundary(): void + { + global $response_value, $response_code; + + $_SERVER['PATH_INFO'] = '/di'; + $_SERVER['REQUEST_URI'] = '/di'; + $_SERVER['REDIRECT_URL'] = '/di'; + + $this->router->route( + 'GET /di', + ['Objectiveweb\\Router\\Tests\\MissingController', 'index'] + ); + + $this->assertInstanceOf(\Throwable::class, $response_value); + $this->assertSame(500, $response_code); + } + + public function testInvalidCallbackIsInsideBoundary(): void + { + global $response_value, $response_code; + + $_SERVER['PATH_INFO'] = '/invalid'; + $_SERVER['REQUEST_URI'] = '/invalid'; + $_SERVER['REDIRECT_URL'] = '/invalid'; + + $this->router->route('GET /invalid', 'not-a-callable'); + + $this->assertInstanceOf(\RuntimeException::class, $response_value); + $this->assertSame(500, $response_code); + } + + public function testThrowableUsesStandardErrorEnvelope(): void + { + $response = Router::prepareResponseForTest( + new \TypeError('Bad argument'), + 'application/json' + ); + + $this->assertSame('application/json', $response['content_type']); + + $body = json_decode($response['body'], true, flags: JSON_THROW_ON_ERROR); + $this->assertSame(\TypeError::class, $body['exception']); + $this->assertSame('Bad argument', $body['message']); + } +} From 54a997236c2fe1c7e9fa5e7447f2dd6999754487 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 29 Sep 2026 10:25:03 -0300 Subject: [PATCH 40/64] Use published Objectiveweb Dice 4.1 --- CHANGELOG.md | 2 +- composer.json | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c73dd0..c749cba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ All notable changes to Objectiveweb Router are documented in this file. ### Changed - Response negotiation now honors `Accept` media ranges, q-values, wildcards, and q=0 exclusions; HTML/JSON responses use explicit content types and unsupported requests receive 406. -- Pin Objectiveweb Dice to the stable `^4.0.4` series. +- Require Objectiveweb Dice `^4.1.0`. - Update JMS Serializer development compatibility to `^3.32`. - Example JMS metadata now uses PHP attributes. - Non-string response bodies are JSON-encoded before output. diff --git a/composer.json b/composer.json index dfe99ad..ed98410 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ ], "require": { "php": ">=8.1", - "objectiveweb/dice": "^4.0.4" + "objectiveweb/dice": "^4.1.0" }, "require-dev": { "phpunit/phpunit": "^10.5 || ^11.5 || ^12.5", @@ -22,11 +22,5 @@ "scripts": { "test": "phpunit test/" }, - "license": "MIT", - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/objectiveweb/Dice" - } - ] + "license": "MIT" } From 66aff42b2777ce8973e5dfc68e4a057e343f3826 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 29 Sep 2026 10:29:04 -0300 Subject: [PATCH 41/64] Validate typed request body media types --- CHANGELOG.md | 7 ++++ src/Router.php | 61 +++++++++++++++++++++++++----- test/ControllerTest.php | 82 +++++++++++++++++++++++++++++++++++++++- test/RequestBodyTest.php | 15 ++++++++ 4 files changed, 154 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c749cba..8b6d405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ All notable changes to Objectiveweb Router are documented in this file. - Route execution now catches all PHP `Throwable` failures, including `TypeError`/`Error`, normalizes invalid exception codes to HTTP 500, and includes dependency-injection/callback resolution inside the HTTP error boundary. - Request body parsing now follows `Content-Type`: JSON (including `+json` media types), URL-encoded forms, multipart forms, and raw/unknown bodies are handled explicitly. +- Class-typed controller request bodies now require a JSON media type, return 415 for unsupported/missing `Content-Type`, and return 400 for malformed JSON instead of surfacing as a server error. - HTTP-method template fallback no longer uses an accidental variable-variable expression. - Controller responses that are not arrays no longer reach the array-only template renderer. - Overridden `respond()` methods work again through late static binding. @@ -113,6 +114,12 @@ All routed responses now enter the common `respond()` pipeline. Custom subclasse A renderable object may return either a completed string body or a structured non-string value. Non-string values are JSON-encoded by the router. +### Request bodies + +Controller methods whose final body parameter is a class are automatically deserialized with JMS Serializer only for `application/json` and `application/*+json` requests. Other or missing media types receive HTTP 415. Malformed JSON receives HTTP 400. + +Array-typed body parameters continue to use the router's Content-Type-aware parser. Unknown media types are preserved as raw bodies where no typed DTO deserialization is requested. + ### Templates The default template directory is now: diff --git a/src/Router.php b/src/Router.php index 6d09d3d..1a5b5ba 100644 --- a/src/Router.php +++ b/src/Router.php @@ -281,14 +281,35 @@ public function controller($path, $controller) case "patch": $rparams = $refMethod->getParameters(); $fn_param = array_pop($rparams); - // auto deserialize when type hinted as class and jms/serializer is available - if ($fn_param && $fn_param->getClass() && class_exists('\JMS\Serializer\SerializerBuilder')) { + $fnType = $fn_param?->getType(); + $fnClass = $fnType instanceof \ReflectionNamedType && !$fnType->isBuiltin() + ? $fnType->getName() + : null; + $fnIsArray = $fnType instanceof \ReflectionNamedType + && $fnType->isBuiltin() + && $fnType->getName() === 'array'; + + // Auto-deserialize class-typed bodies only when the request + // explicitly declares a JSON media type. + if ($fnClass && class_exists('\JMS\Serializer\SerializerBuilder')) { + $contentType = static::requestContentType(); + if (!static::isJsonContentType($contentType)) { + throw new \RuntimeException( + sprintf( + 'Unsupported Content-Type "%s"; expected application/json', + $contentType ?: '(missing)' + ), + 415 + ); + } + + $body = Router::parse_post_body(false); + static::decodeJsonBody($body); + $serializer = \JMS\Serializer\SerializerBuilder::create()->build(); - $type = new \JMS\Serializer\Annotation\Type; - $params[] = $serializer->deserialize(Router::parse_post_body(false), - $fn_param->getClass()->getName(), 'json'); + $params[] = $serializer->deserialize($body, $fnClass, 'json'); } // hinting as array allows overriding _deserialize - elseif ($fn_param && $fn_param->isArray()) { + elseif ($fnIsArray) { $params[] = Router::parse_post_body(); } // use _deserialize as the default parser for non-type-hinted methods elseif (is_callable(array($controller, '_deserialize'))) { @@ -603,12 +624,32 @@ public static function url($str = null) } } - public static function parse_post_body($decoded = true, $as_array = true) + private static function requestContentType(): string { - $contentType = strtolower(trim(explode( + return strtolower(trim(explode( ';', $_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? '' )[0])); + } + + private static function isJsonContentType(string $contentType): bool + { + return $contentType === 'application/json' + || str_ends_with($contentType, '+json'); + } + + private static function decodeJsonBody(string $body, bool $asArray = true): mixed + { + try { + return json_decode($body, $asArray, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $ex) { + throw new \RuntimeException('Invalid JSON request body', 400, $ex); + } + } + + public static function parse_post_body($decoded = true, $as_array = true) + { + $contentType = static::requestContentType(); // $_POST is normally an array populated by PHP for form requests. // Keeping string support is useful for tests and callers that inject a raw body. @@ -620,8 +661,8 @@ public static function parse_post_body($decoded = true, $as_array = true) return $postBody; } - if ($contentType === 'application/json' || str_ends_with($contentType, '+json')) { - return json_decode($postBody, $as_array); + if (static::isJsonContentType($contentType)) { + return static::decodeJsonBody($postBody, $as_array); } if ($contentType === 'application/x-www-form-urlencoded') { diff --git a/test/ControllerTest.php b/test/ControllerTest.php index f2a7769..a7142c5 100644 --- a/test/ControllerTest.php +++ b/test/ControllerTest.php @@ -36,7 +36,7 @@ protected function setUp(): void $_SERVER['SCRIPT_NAME'] = '/index.php'; $_SERVER['REQUEST_URI'] = '/'; $_SERVER['REDIRECT_URL'] = '/'; - unset($_SERVER['HTTP_ACCEPT']); + unset($_SERVER['HTTP_ACCEPT'], $_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_CONTENT_TYPE']); global $response_value, $response_code; $response_value = null; @@ -93,6 +93,86 @@ public function testPost(): void $this->assertSame(89.99, $repository->get(10)->price); } + public function testClassTypedBodyRejectsUnsupportedContentType(): void + { + global $response_value, $response_code; + + $controller = $this->app->create(ProductsController::class); + $repository = $this->app->create('App\DB\ProductsRepository'); + + $_POST = 'sku=10&name=Test+Product&price=89.99'; + $_SERVER['PATH_INFO'] = '/'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['REDIRECT_URL'] = '/'; + + $this->app->controller('/', $controller); + + $this->assertInstanceOf(\RuntimeException::class, $response_value); + $this->assertSame(415, $response_code); + $this->assertSame(2, $repository->count()); + } + + public function testClassTypedBodyRejectsMissingContentType(): void + { + global $response_value, $response_code; + + $controller = $this->app->create(ProductsController::class); + + $_POST = '{"sku":10,"name":"Test Product","price":89.99}'; + $_SERVER['PATH_INFO'] = '/'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['REDIRECT_URL'] = '/'; + + $this->app->controller('/', $controller); + + $this->assertInstanceOf(\RuntimeException::class, $response_value); + $this->assertSame(415, $response_code); + } + + public function testClassTypedBodyRejectsMalformedJson(): void + { + global $response_value, $response_code; + + $controller = $this->app->create(ProductsController::class); + $repository = $this->app->create('App\DB\ProductsRepository'); + + $_POST = '{"sku":10,"name":"broken"'; + $_SERVER['PATH_INFO'] = '/'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['REDIRECT_URL'] = '/'; + + $this->app->controller('/', $controller); + + $this->assertInstanceOf(\RuntimeException::class, $response_value); + $this->assertSame('Invalid JSON request body', $response_value->getMessage()); + $this->assertSame(400, $response_code); + $this->assertSame(2, $repository->count()); + } + + public function testClassTypedBodyAcceptsStructuredJsonMediaType(): void + { + global $response_value; + + $controller = $this->app->create(ProductsController::class); + + $_POST = '{"sku":10,"name":"Test Product","price":89.99}'; + $_SERVER['PATH_INFO'] = '/'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/vnd.objectiveweb+json'; + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['REDIRECT_URL'] = '/'; + + $this->app->controller('/', $controller); + + $this->assertInstanceOf(Product::class, $response_value); + $this->assertSame(10, $response_value->sku); + } + public function testPut(): void { global $response_value; diff --git a/test/RequestBodyTest.php b/test/RequestBodyTest.php index d5f3e39..68cb05c 100644 --- a/test/RequestBodyTest.php +++ b/test/RequestBodyTest.php @@ -31,6 +31,21 @@ public function testApplicationJsonDecodesJsonScalars(): void $this->assertNull(Router::parse_post_body()); } + public function testMalformedJsonThrows400(): void + { + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_POST = '{"ok":'; + + try { + Router::parse_post_body(); + $this->fail('Expected malformed JSON to throw'); + } catch (\RuntimeException $ex) { + $this->assertSame(400, $ex->getCode()); + $this->assertSame('Invalid JSON request body', $ex->getMessage()); + $this->assertInstanceOf(\JsonException::class, $ex->getPrevious()); + } + } + public function testStructuredJsonMediaTypeIsDecoded(): void { $_SERVER['CONTENT_TYPE'] = 'application/problem+json'; From 16c7eb30e631733065ba80dc0a2761bc1cab9ba1 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 29 Sep 2026 10:32:49 -0300 Subject: [PATCH 42/64] Preserve error status during negotiation --- CHANGELOG.md | 3 ++ src/Router.php | 72 +++++++++++++++++++++++++++++--- test/ResponseNegotiationTest.php | 67 +++++++++++++++++++++++++++++ test/TestableRouter.php | 9 ++++ 4 files changed, 145 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b6d405..7d06250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ All notable changes to Objectiveweb Router are documented in this file. ### Fixed +- Default Throwable responses now negotiate HTML or JSON without replacing the original 4xx/5xx status with 406 for HTML clients. - Route execution now catches all PHP `Throwable` failures, including `TypeError`/`Error`, normalizes invalid exception codes to HTTP 500, and includes dependency-injection/callback resolution inside the HTTP error boundary. - Request body parsing now follows `Content-Type`: JSON (including `+json` media types), URL-encoded forms, multipart forms, and raw/unknown bodies are handled explicitly. - Class-typed controller request bodies now require a JSON media type, return 415 for unsupported/missing `Content-Type`, and return 400 for malformed JSON instead of surfacing as a server error. @@ -110,6 +111,8 @@ public function before(string $method, string $fn, array $params): array Response representation is now negotiated from the request `Accept` header. Controller array results with a matching template can be rendered as `text/html` or returned as `application/json`; missing `Accept` behaves like `*/*` and prefers HTML when a template is available. Requests that reject all available representations receive HTTP 406. +Default error responses support both HTML and JSON representations, so an existing 4xx/5xx status is preserved for clients accepting either representation. A request that accepts neither can still receive HTTP 406. + All routed responses now enter the common `respond()` pipeline. Custom subclasses overriding `respond()` therefore see normal responses, registered-serializer responses, and exceptions. A renderable object may return either a completed string body or a structured non-string value. Non-string values are JSON-encoded by the router. diff --git a/src/Router.php b/src/Router.php index 1a5b5ba..9a71269 100644 --- a/src/Router.php +++ b/src/Router.php @@ -801,9 +801,49 @@ public static function negotiateContentType(array $available, ?string $accept = protected static function prepareResponse($content, ?string $accept = null): array { if ($content instanceof \Throwable && !self::hasSerializer(get_class($content))) { - $content = [ - 'exception' => get_class($content), - 'message' => $content->getMessage(), + $contentType = static::negotiateContentType( + ['text/html', 'application/json'], + $accept + ); + + if ($contentType === null) { + return [ + 'body' => '', + 'content_type' => null, + 'vary_accept' => true, + ]; + } + + if ($contentType === 'text/html') { + $exception = htmlspecialchars( + get_class($content), + ENT_QUOTES | ENT_SUBSTITUTE, + 'UTF-8' + ); + $message = htmlspecialchars( + $content->getMessage(), + ENT_QUOTES | ENT_SUBSTITUTE, + 'UTF-8' + ); + + return [ + 'body' => sprintf( + '

%s

%s

', + $exception, + $message + ), + 'content_type' => 'text/html', + 'vary_accept' => true, + ]; + } + + return [ + 'body' => static::serializeJson([ + 'exception' => get_class($content), + 'message' => $content->getMessage(), + ]), + 'content_type' => 'application/json', + 'vary_accept' => true, ]; } @@ -894,10 +934,30 @@ private static function serializeJson($content, ?object $obj = null): string return json_encode($content, JSON_THROW_ON_ERROR); } + /** + * Prepare the final HTTP response plan, including the status selected after + * representation negotiation. + * + * @return array{status:int, body:string, content_type:?string, vary_accept:bool} + */ + protected static function prepareHttpResponse( + $content, + int $code = 200, + ?string $accept = null + ): array { + $response = static::prepareResponse($content, $accept); + + return [ + 'status' => $response['content_type'] === null ? 406 : $code, + ...$response, + ]; + } + public static function respond($content, $code = 200) { - $response = static::prepareResponse( + $response = static::prepareHttpResponse( $content, + $code, $_SERVER['HTTP_ACCEPT'] ?? null ); @@ -905,12 +965,12 @@ public static function respond($content, $code = 200) header('Vary: Accept', false); } + header("HTTP/1.1 {$response['status']}"); + if ($response['content_type'] === null) { - header('HTTP/1.1 406'); exit(''); } - header("HTTP/1.1 $code"); header('Content-Type: ' . $response['content_type'] . '; charset=utf-8'); exit($response['body']); diff --git a/test/ResponseNegotiationTest.php b/test/ResponseNegotiationTest.php index 3cf102e..6e41ce2 100644 --- a/test/ResponseNegotiationTest.php +++ b/test/ResponseNegotiationTest.php @@ -65,6 +65,73 @@ public function testHtmlStringRemainsRaw(): void $this->assertSame('Hello', $response['body']); } + public function testThrowablePreserves404ForHtmlClient(): void + { + $response = Router::prepareHttpResponseForTest( + new \RuntimeException('Missing', 404), + 404, + 'text/html' + ); + + $this->assertSame(404, $response['status']); + $this->assertSame('text/html', $response['content_type']); + $this->assertStringContainsString('RuntimeException', $response['body']); + $this->assertStringContainsString('Missing', $response['body']); + $this->assertTrue($response['vary_accept']); + } + + public function testThrowablePreserves500ForHtmlClient(): void + { + $response = Router::prepareHttpResponseForTest( + new \TypeError('Bad argument'), + 500, + 'text/html' + ); + + $this->assertSame(500, $response['status']); + $this->assertSame('text/html', $response['content_type']); + } + + public function testThrowablePreservesStatusForJsonClient(): void + { + $response = Router::prepareHttpResponseForTest( + new \RuntimeException('Missing', 404), + 404, + 'application/json' + ); + + $this->assertSame(404, $response['status']); + $this->assertSame('application/json', $response['content_type']); + + $body = json_decode($response['body'], true, flags: JSON_THROW_ON_ERROR); + $this->assertSame(\RuntimeException::class, $body['exception']); + $this->assertSame('Missing', $body['message']); + } + + public function testThrowableHtmlEscapesMessage(): void + { + $response = Router::prepareHttpResponseForTest( + new \RuntimeException('', 500), + 500, + 'text/html' + ); + + $this->assertStringNotContainsString('