diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e2f3641 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +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@v7 + + - 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 + + lowest: + name: PHP 8.1 / lowest dependencies + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - 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: Install lowest compatible dependencies + run: composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction --no-progress + + - name: Run tests + run: composer test + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f8538a2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release verification + +on: + push: + branches: + - master + 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@v7 + + - 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@v7 + + - 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/.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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..16f256c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,224 @@ +# Changelog + +All notable changes to Objectiveweb Router are documented in this file. + +## [3.0.0] - 2026-09-30 + +### Breaking changes + +- Remove the legacy static `Router::render()` helper; PHP templates are represented by `Template` objects created through `$router->template()` or controller template lookup. +- Controller template directories no longer auto-include `_functions.php`; application helpers must be loaded explicitly by the application/bootstrap or provided through template objects/data. +- `url()` and `redirect()` are now Router instance methods so URL generation can use per-router trusted-proxy configuration. +- Direct `HTTP_HOST` is no longer trusted by default for absolute URL generation; configure `trusted.hosts` explicitly or use `'*'` to retain unrestricted host behavior. +- `create()` no longer exposes Dice's internal third `share` argument; the supported Router DI API is `create(string $name, array $args = []): object`. +- 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 + +- Routing regression coverage for raw regex captures, route extra arguments, controller regex constructor captures, immediate first-match dispatch/no-match behavior, DELETE, controller PATCH, OPTIONS method-specific actions, and custom HTTP-method actions. +- Explicit `trusted.proxies` IP/CIDR policy for `X-Forwarded-Proto`, `X-Forwarded-Host`, and `X-Forwarded-Port`. +- Explicit `trusted.hosts` allowlist for direct `HTTP_HOST` values, with `'*'` as an opt-in wildcard. +- `Template::url()` delegates to the owning Router for proxy-aware URL generation inside PHP templates. +- `PATCH()` route helper with the same Content-Type-aware request-body handling as POST and PUT. +- Automatic HEAD fallback for `GET()` helpers and controller GET actions. +- DI-backed global request middleware with declaration-order `before()` hooks before route matching and reverse-order `after()` unwinding when a route produces a response. +- Built-in `CorsMiddleware` for global CORS headers and terminating OPTIONS preflight requests. +- 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 + +- Type the `Middleware` attribute internals and API: `private array $args`, `mixed ...$args`, `getClass(): string`, and `getArgs(): array`. +- Request middleware interface documentation now matches runtime behavior: request `before()` hooks run once before route matching starts; stale HEAD-specific controller examples were removed. +- `route()` and `controller()` now expose their supported additional arguments explicitly as `mixed ...$args` instead of relying on hidden `func_get_args()` behavior. +- Tighten straightforward public Router method signatures with PHP 8.1 parameter and return types while keeping route callbacks `mixed` so invalid callbacks stay inside the controlled HTTP error boundary. +- Keep `isAjax()` as a typed `bool` request helper and remove the unused private `_call()` helper and stale JMS import. +- HEAD responses preserve GET representation semantics while suppressing the response body; 1xx, 204, 205, and 304 responses never carry a body. +- Documentation now explicitly defines Objectiveweb Router as an immediate regex dispatcher rather than a route-table dispatcher. +- `setCors()` now registers the built-in request-level `CorsMiddleware`; controller-specific CORS branching has been removed. +- GitHub Actions workflows now use `actions/checkout@v7`, removing the deprecated Node 20 action runtime warning. +- Test execution now fails on PHP/PHPUnit deprecations so the supported PHP matrix remains deprecation-clean. +- 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. +- 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. +- 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 + +- CORS wildcard origins now automatically disable credentials, preventing the browser-invalid `Access-Control-Allow-Origin: *` plus `Access-Control-Allow-Credentials: true` combination; integration coverage now includes explicit no-credentials, allow-header lists, and `setCors()` replacement behavior. +- Unhandled 5xx responses now redact exception class/message by default while preserving server-side logging; `debug => true` restores detailed development responses, 4xx details remain visible, and explicitly registered exception serializers are unchanged. +- Template rendering now uses typed paths/return values, `EXTR_SKIP` variable extraction, protected layout contents, and exception-safe output-buffer cleanup. +- Middleware instantiation no longer passes a synthetic string through Dice's internal `share` argument; repeated middleware remain distinct while normally configured shared dependencies are still reused. +- Public README, controller/middleware documentation, and runnable examples now describe the v3 API and no longer reference legacy Dice includes or removed controller hooks. +- `GET()`, `POST()`, `PUT()`, and `DELETE()` now share the same callback resolution and Throwable boundary as `route()`, including Dice-backed class callbacks and request argument preparation. +- Example controller dependencies are explicitly declared properties, removing the PHP 8.2+ dynamic-property deprecation. +- 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. +- 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. + +The Router-level `create()` method accepts only the class name and explicit constructor arguments. Dice's internal object-graph `share` argument is intentionally not part of the Router API. + +### 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 + +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. + +### HEAD controller actions + +Router v2 could resolve HEAD requests to controller methods such as `head()` and `headSale()`. Router v3 deliberately follows normal HTTP HEAD fallback semantics instead: + +- `HEAD /products` resolves through the same controller action as GET, typically `index()`. +- `HEAD /products/42` resolves through `get('42', ...)`. +- `HEAD /products/sale` resolves through `getSale()`, then the normal GET/custom fallback rules. +- The final response keeps the GET representation/status headers but suppresses the body. + +Applications using v2 `head()` or `headFoo()` controller methods should move that behavior into the corresponding GET handler or request middleware. + +### 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: + +```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. + +### Template rendering and helpers + +The v2 static file renderer has been removed: + +```php +// v2 +$html = Router::render($file, $data); +``` + +Use a Router-owned `Template` in v3: + +```php +$template = $router->template('page', $data); +$html = $template?->render(); +``` + +Controller template lookup creates the same `Template` objects automatically. + +v2 also loaded a sibling `_functions.php` automatically before rendering a controller template. v3 does not execute implicit helper files. Load application helpers from the bootstrap/autoloader instead, or expose behavior explicitly through template data or the Template API. Router-owned templates already provide `$this->url()`. + +### URL generation and redirects + +`url()` and `redirect()` are no longer static: + +```php +// v2 +Router::url('/products'); +Router::redirect('/login'); + +// v3 +$router->url('/products'); +$router->redirect('/login'); +``` + +Relative redirect targets still use Router URL generation. Absolute `http://` and `https://` redirect targets are now passed through unchanged instead of being treated as application-relative paths. Absolute current-request URL generation uses the v3 `trusted.proxies` policy documented in the README. + +### 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 5e6d391..b9a7597 100644 --- a/README.md +++ b/README.md @@ -1,266 +1,330 @@ -# 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. +Lightweight PHP URL router with controller mapping and dependency injection. -## Instalation +## Requirements -Add the dependency to `composer.json`, then `composer install` +- PHP 8.1+ +- Composer - { - "require": { - "objectiveweb/router": "~2.0" - } +## Installation + +```bash +composer require objectiveweb/router:^3.0 +``` + +## Basic routing + +```php +GET('/?', function () { + return 'Hello index'; +}); + +$router->GET('/([a-z]+)/?', function (string $key, array $query) { + if ($key === 'data') { + return [1, 2, 3]; } -## Basic Usage + throw new RuntimeException("Unknown key: $key", 404); +}); + +$router->POST('/echo', function (array $body) { + return $body; +}); + +$router->route('([A-Z]+) /(.*)', function (string $method, string $path) { + return "Request matched $method /$path"; +}); +``` -In Objectiveweb applications, an endpoint refers to a php file which responds to url routes. +The verb helpers append request data after regex captures: -Under the hood, the `route($regex, $callable)` function tests `$regex` against the current -request method and uri (e.g. "GET endpoint.php/something/([0-9]+)/?"), passing the captured -parameters to `$callable` when matched. +- `GET()` and `DELETE()` append `$_GET`. +- `POST()`, `PUT()`, and `PATCH()` append the Content-Type-aware decoded request body. +- `GET()` also matches `HEAD` requests. The callback runs with GET semantics, but Router suppresses the final response body. +- `route()` passes regex captures directly, plus any additional arguments supplied to `route()`. -### Example endpoint +Callbacks may also be written as `[Controller::class, 'method']`. Class-name callbacks are instantiated through Dice before invocation. - GET('/?', function() { - echo "Hello index'; - }); +## Routing model - $app->GET('/([a-z]+)/?', function($key) { +Objectiveweb Router is an **immediate regex dispatcher**, not a complete route-table dispatcher. Calls to `route()`, the HTTP verb helpers, and `controller()` evaluate regular expressions against the current request and may execute immediately; Router does not first collect every application route into a route table. - switch($key) { - case 'data': - // If the callback returns something, it's sent with status 200 (OK) - // Arrays are automatically encoded to json - return [ 1, 2, 3 ]; - break; - default: - throw new \Exception([ 'key' => $key, 'empty' => true ], 404); // respond with custom code - break; - } - }); +This is intentional: regular-expression routing and capture groups are first-class behavior, including controller-path captures that can be forwarded to controller constructors. - $app->POST('/panic', function() { - // Exceptions are captured, message is send with the proper code - throw new \Exception('Panic!', 500); - }); - - // catch all - $app->router("([A_Z]+) (.*), function ($method, $path) { - return "Request matched $method and $path"; - }); +Because Router does not own a complete route table, it does not synthesize route-table features such as named routes, reverse URL generation, route introspection, automatic generic `OPTIONS`, or global `405 Method Not Allowed` / `Allow` responses. Applications can register explicit regex routes or request middleware when those behaviors are needed. - * GET http://server/endpoint.php displays `Hello index` - * GET http://server/endpoint.php/data returns a json-encoded array with values 1, 2, 3 - * GET http://server/endpoint.php/test returns a not found error with a json object on its body - * POST http://server/endpoint.php/panic raises a 500 Internal server error with 'Panic!' as the response +The convenience `GET()` helper and controller dispatcher implement normal `HEAD` fallback to GET semantics. Raw `route()` remains exactly the regex supplied by the application; use a pattern such as `(?:GET|HEAD) /path` when the raw route should accept both methods. + +For controllers, this is an intentional v3 behavior change: legacy v2 `head()` and `headFoo()` handlers no longer receive HEAD requests. Define the GET handler instead; Router executes it with HEAD response-body suppression. ## Controllers -PHP classes may be bound to an url using the `controller($path, $class)` on public-facing endpoint (index.php) - - $app->controller('/', 'ExampleController'); - -In this case, the request is mapped to the corresponding class method as follows - - controller('/products', App\ProductsController::class); +``` - // (GET|POST|PUT|...) /example/(.*) - function example($path[1], $path[2], ...) { - // check $_SERVER['REQUEST_METHOD'] and process data - } - -This function name may also be prefixed with the request method. In this case the query parameters are passed as the -last argument +Controller paths are regular expressions. Capture groups in the controller path are passed to the controller constructor after any explicit constructor arguments supplied to `controller()`: + +```php +$router->controller( + '/accounts/([0-9]+)/regions/([a-z]+)', + App\AccountController::class, + 'explicit-argument' +); +``` + +For `GET /accounts/42/regions/us/products`, Router constructs the controller as if Dice had been called with: + +```php +$router->create(App\AccountController::class, [ + 'explicit-argument', + '42', + 'us', +]); +``` + +The remaining `products` path is then used for controller method resolution. Use non-capturing groups such as `(?:...)` when a regex group should affect matching without becoming a constructor argument. + +Controller resolution follows these rules: + +| Request | Preferred controller method | +| --- | --- | +| `GET /products` | `index($_GET)` | +| `POST /products` | `post($body)` | +| `PUT /products` | `put($body)` | +| `PATCH /products` | `patch($body)` | +| `GET /products/42` | `get('42', $_GET)` | +| `POST /products/42` | `post('42', $body)` | +| `GET /products/sale` | `getSale($_GET)`, then `sale($_GET)`, then `get('sale', $_GET)` | +| `POST /products/sale` | `postSale($body)`, then `sale($body)`, then `post('sale', $body)` | + +Hyphens in custom path method names are converted to underscores. - // GET /example/(.*) - function getExample($path[1], $path[2], ..., $_GET) { - +For POST, PUT, and PATCH controller methods, the request body is appended as the final argument. If that final parameter is a class and JMS Serializer is installed, JSON and `application/*+json` requests are deserialized into that class. Unsupported or missing media types return 415 for class-typed bodies; malformed JSON returns 400. + +See [controller mapping](doc/controller.md) for the full behavior. + +## Middleware + +Request/response interception uses repeatable `#[Middleware]` attributes rather than controller `before()` hooks. + +```php +use Objectiveweb\Router\Middleware; +use Objectiveweb\Router\MiddlewareInterface; + +class AuthenticationMiddleware implements MiddlewareInterface +{ + public function before(string $method, string $fn, array $params): array + { + // Validate or modify controller arguments. + return $params; } - - // POST /example/(.*) - function postExample($path[1], $path[2], ..., $decoded_post_body) { - + + public function after( + string $method, + string $fn, + array $params, + mixed $response + ): mixed { + return $response; } - -Other request methods are also valid (i.e. HEAD, OPTIONS, etc), check the example subdir for other uses. - -### Automatic routing - -You can bootstrap the application on a particular namespace using - - $app->run('Namespace'); - -When run, the Router automatically maps the incoming requests to the given namespace. For example, a request to -/products would instantiate the `Namespace\ProductsController` class. - -If the Controller doesn't exist, the request is passed to the `Namespace\HomeController`. Check `example/app-run.php` -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. - - addRule('PDO', [ - 'shared' => true, - 'constructParams' => ['mysql:host=127.0.0.1;dbname=mydb', 'username', 'password'] - ]); - - // From now on, you can get a configured PDO instance using - $pdo = $app->create('PDO'); - -When bound to paths, Controller (And dependencies of those dependencies) get automatically resolved. -For example, if you define the controller - - pdo = pdo; - } - - function index() { - // query the database using $this->pdo - } +} + +#[Middleware(AuthenticationMiddleware::class)] +class ProductsController +{ + public function index(array $query): array + { + return []; } +} +``` + +See [middleware documentation](docs/middleware.md) for ordering and override rules. + +### Global request middleware + +Request middleware wraps the incoming request and is constructed through the same Dice container as controllers. Its `before()` hooks run once before route matching begins: + +```php +$router = new Router(null, [ + 'request.middlewares' => [ + RequestIdMiddleware::class => [], + ], +]); + +$router->addRequestMiddleware(TracingMiddleware::class, ['http']); +``` + +Request middleware `before(string $method, string $path)` hooks run in declaration order. When a route produces a response, `after(string $method, string $path, mixed $response)` hooks run in reverse order and may transform that response. Hooks are optional when the class does not implement `RequestMiddlewareInterface`. + +The built-in CORS middleware can be enabled with the compatibility helper: + +```php +$router->setCors('https://app.example'); +``` + +For public wildcard CORS, `$router->setCors('*')` automatically disables credentials so Router never emits the invalid `Access-Control-Allow-Origin: *` + `Access-Control-Allow-Credentials: true` combination. + +It can also be registered/configured directly: + +```php +use Objectiveweb\Router\CorsMiddleware; -When `MyApplication\MyController` gets instantiated by the Router, a configured instance of PDO will -be injected and reused as necessary. +$router->addRequestMiddleware(CorsMiddleware::class, [ + 'https://app.example', +]); +``` -You can inject dependencies adding type-hinted parameters to your controller's constructor: +CORS preflight requests terminate before controller resolution. - function __construct(\Util\Gmaps $gmaps, \DB\ProductsRepository $products) { - $this->gmaps = $gmaps; - $this->products = $products; +Middleware may reject a request by throwing an HTTP exception, or terminate immediately with `Router::respond()`, `$router->redirect()`, or `exit()`. Hard termination skips the controller, remaining middleware, and all `after()` hooks. + +## Dependency injection + +Router composes Objectiveweb Dice and exposes `addRule()` and `create()` as its supported DI API. + +```php +$router->addRule(PDO::class, [ + 'shared' => true, + 'constructParams' => [ + 'mysql:host=127.0.0.1;dbname=mydb', + 'username', + 'password', + ], +]); + +$pdo = $router->create(PDO::class); +``` + +Controllers and class callbacks are instantiated through the same container: + +```php +class ProductsController +{ + public function __construct(private ProductsRepository $products) + { } - - // Use $this->gmaps and $this->products on other functions - -In a another example, let's instantiate Twig - - // index.php - - $app = new \Objectiveweb\Router(); - - $app->addRule('Twig_Loader_Filesystem', array( - 'shared' => true, - 'constructParams' => [ TEMPLATE_ROOT ] - )); - - $app->addRule('Twig_Environment', array( - 'shared' => true, - 'constructParams' => [ - [ 'instance' => 'Twig_Loader_Filesystem' ], - [ 'cache' => APP_ROOT.'/cache' ], - [ 'auto_reload' => true ] - ], - 'call' => [ - [ 'addGlobal', [ 'server', $_SERVER['SERVER_NAME'] ] ], - [ 'addGlobal', [ 'app_name', APP_NAME ] ], - [ 'addGlobal', [ 'session', $_SESSION ] ], - [ 'addFunction', [ new Twig_SimpleFunction('url', function ($path) { - return \Objectiveweb\Router::url($path); - })]] - ] - )); - - $app->controller('/', 'MyController') - -Then, inject it on your controller's constructor - - class MyController { - - private $twig; - private $pdo; - - function __construct(Twig_Environment $twig, PDO $pdo) { - $this->twig = $twig; - $this->pdo = $pdo; - } - - function index() { - return $this->twig->render(...); - } + + public function index(array $query): array + { + return $this->products->index(); } - -You can also fetch the twig reference using - - $twig = $app->create('Twig_Environment'); - -### Rules - -Dice Rules can be configured with these properties: - - * shared (boolean) - Whether a single instance is used throughout the container. - [View Example](https://r.je/dice.html#example2-2) - * inherit (boolean) - Whether the rule will also apply to subclasses (defaults to true). - [View Example](https://r.je/dice.html#example3-2) - * constructParams (array) - Additional parameters passed to the constructor. - [View Example](https://r.je/dice.html#example3-3) - * substitutions (array) - key->value substitutions for dependencies. - [View Example](https://r.je/dice.html#example3-1) - * call (multidimensional array) - A list of methods and their arguments which will be - called after the object has been constructed. [View Example](https://r.je/dice.html#example3-4) - * instanceOf (string) - The name of the class to initiate. Used when the class name is not passed - to `$app->addRule()`. [View Example](https://r.je/dice.html#example3-6) - * shareInstances (array) - A list of class names that will be shared throughout a single object - tree. [View Example](https://r.je/dice.html#example3-7) +} + +$router->controller('/products', ProductsController::class); +``` + +## Trusted hosts, proxies, and URL generation + +`$router->url()` and `$router->redirect()` are instance methods because absolute URL generation depends on Router configuration. + +Direct `HTTP_HOST` values are ignored by default. Configure the hostnames that are allowed to affect absolute URL generation: + +```php +$router = new Router(null, [ + 'trusted.hosts' => [ + 'example.com', + 'api.example.com', + ], +]); +``` + +Trusted host entries are hostnames only; request ports are matched independently. Matching is case-insensitive, ignores a trailing DNS dot, and normalizes IP literals. Set `'trusted.hosts' => '*'` to accept any syntactically valid direct `HTTP_HOST`. When a direct host is not trusted, Router falls back to `SERVER_NAME` and `SERVER_PORT`. + +Forwarded headers are also ignored by default. Configure the exact proxy addresses or CIDR ranges that are allowed to supply external request metadata: + +```php +$router = new Router(null, [ + 'trusted.hosts' => ['example.com'], + 'trusted.proxies' => [ + '127.0.0.1', + '10.42.0.0/16', + '2001:db8:42::/48', + ], +]); +``` + +Only when `REMOTE_ADDR` matches one of these proxy entries can `X-Forwarded-Proto`, `X-Forwarded-Host`, and `X-Forwarded-Port` affect `url('self')` / `url()`. Forwarded host values are validated separately and do not need to appear in `trusted.hosts`. + +Objectiveweb Router intentionally does not interpret comma-separated proxy chains. A trusted proxy is expected to remove client-supplied forwarding headers and write one authoritative value. Comma-separated or malformed forwarded values are ignored. + +Private address ranges are not trusted automatically. Add only the actual proxy/network ranges controlled by the application infrastructure. + +## Templates + +Controller methods that return arrays may be rendered through PHP templates. The default template root is: + +```text +/templates +``` + +For a controller bound to `/products`, Router looks for a method-specific template first and then an HTTP-method fallback. If both HTML and JSON representations are available, the request `Accept` header selects the representation. + +Templates created by Router expose URL generation through the Template object itself: + +```php +Products +``` + +This delegates to the owning Router, so template URL generation uses the same trusted-proxy policy without injecting a reserved `$router` or `$url` variable into template data. + +You can configure the template root and layout when constructing the router: + +```php +$router = new Router(__DIR__, [ + 'template.root' => __DIR__ . '/templates', + 'template.layout' => 'main', +]); +``` + +## Error disclosure + +Unhandled 5xx errors are redacted by default. Router logs the original Throwable, while clients receive only `Internal Server Error` in the negotiated HTML or JSON representation. + +For local development, detailed 5xx error responses can be enabled explicitly: + +```php +$router = new Router(null, [ + 'debug' => true, +]); +``` + +Debug mode exposes the exception class and message and should not be enabled in production. 4xx exception details remain visible by default, and exceptions with explicitly registered serializers continue to use those serializers. + +## Response negotiation + +Router negotiates supported representations from `Accept`, including q-values, wildcards, and q=0 exclusions. `HEAD` uses the same representation selection as GET but never emits a body. Informational responses and statuses `204`, `205`, and `304` also never emit a body. + +- Structured PHP values are JSON responses. +- Strings and renderable objects can provide HTML or JSON. +- Controller arrays with matching templates can provide HTML or JSON. +- Default Throwable responses can provide HTML or JSON while preserving their original 4xx/5xx status. +- If no available representation is acceptable, Router returns 406. + +## Automatic controller routing + +```php +$router->run('App'); +``` + +A request such as `/products` maps to `App\ProductsController`. Root and unmatched controller names fall back to `App\HomeController`. + +See `example/app-run.php` for a complete example. diff --git a/composer.json b/composer.json index 4fdbbf4..639b56c 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": ">=5.4.0", - "level-2/dice": "^2.0" + "php": ">=8.1", + "objectiveweb/dice": "^4.1.0" }, "require-dev": { - "phpunit/phpunit": "4.*", - "jms/serializer": "~1.1" + "phpunit/phpunit": "^10.5 || ^11.5 || ^12.5", + "jms/serializer": "^3.32" + }, + "scripts": { + "test": "phpunit --fail-on-deprecation --display-deprecations test/" }, "license": "MIT" } diff --git a/doc/controller.md b/doc/controller.md new file mode 100644 index 0000000..4b0e3f1 --- /dev/null +++ b/doc/controller.md @@ -0,0 +1,188 @@ +# Controller mapping + +`Router::controller($path, $controller, ...$constructorArgs)` binds a URL prefix to a controller instance or class name. + +```php +$router->controller('/products', App\ProductsController::class); +``` + +When a class name is supplied, Router constructs it through Dice. Additional arguments passed to `controller()` are available during controller construction. + +## Regex controller paths + +The `$path` argument is a regular expression, not only a literal prefix. Any capture groups inside it are passed to the controller constructor. + +```php +$router->controller( + '/accounts/([0-9]+)/regions/([a-z]+)', + AccountController::class +); +``` + +Given: + +```text +GET /accounts/42/regions/us/products +``` + +the path regex captures `42` and `us`. Router uses those values as constructor arguments, while the unmatched remainder, `products`, continues through normal controller method resolution: + +```php +class AccountController +{ + public function __construct( + AccountRepository $accounts, + string $accountId, + string $region + ) { + } + + public function get(string $path, array $query) + { + // $path === 'products' + } +} +``` + +Explicit arguments passed after the controller are placed before regex captures: + +```php +$router->controller( + '/accounts/([0-9]+)', + AccountController::class, + 'explicit' +); +``` + +For `/accounts/42/...`, Dice receives constructor arguments in this order: + +```php +['explicit', '42'] +``` + +Use a non-capturing group when regex grouping is needed only for matching: + +```php +$router->controller('/api/(?:v1|v2)', ApiController::class); +``` + +Only capturing groups `(...)` are forwarded to the constructor; non-capturing groups `(?:...)` are not. + +## Method resolution + +For a controller bound to `/products`, Router resolves requests as follows. + +### Base path + +| Request | Method | +| --- | --- | +| `GET /products` | `index()` | +| `HEAD /products` | `index()` using GET semantics; response body is suppressed | +| `POST /products` | `post()` | +| `PUT /products` | `put()` | +| `PATCH /products` | `patch()` | +| `DELETE /products` | `delete()` | + +### Path parameters + +If the first path segment does not identify a custom controller method, it remains an argument to the HTTP-method handler: + +| Request | Method | +| --- | --- | +| `GET /products/42` | `get('42', ...)` | +| `HEAD /products/42` | `get('42', ...)` using GET semantics | +| `POST /products/42` | `post('42', ...)` | +| `PUT /products/42` | `put('42', ...)` | +| `DELETE /products/42` | `delete('42', ...)` | + +Additional path segments are passed in order. + +HEAD is always resolved with GET controller semantics in v3. A base-path HEAD request uses `index()`, and a path/custom HEAD request uses the corresponding `get()` / `getFoo()` resolution. Controller methods named `head()` or `headFoo()` are therefore not selected for HEAD requests. + +### Custom methods + +For a non-empty first segment, Router checks: + +1. HTTP-method-prefixed method: `getSale()`, `postSale()`, etc. HEAD uses the corresponding GET method name. +2. Unprefixed method: `sale()`. +3. The normal HTTP-method handler, keeping the segment as an argument. + +For example: + +| Request | Resolution order | +| --- | --- | +| `GET /products/sale` | `getSale()` → `sale()` → `get('sale', ...)` | +| `POST /products/sale` | `postSale()` → `sale()` → `post('sale', ...)` | + +When a custom method is selected, the segment naming that method is removed from the argument list. Hyphens in custom path method names are converted to underscores. + +## Request arguments + +After URL parameters are resolved, Router appends request data. + +### GET, DELETE, HEAD, OPTIONS, and other non-body methods + +`$_GET` is appended as the final controller argument. + +```php +public function get(string $sku, array $query): Product +{ + // ... +} +``` + +### POST, PUT, and PATCH + +The request body is appended as the final argument. + +An array-typed final parameter uses Router's Content-Type-aware body parser: + +```php +public function put(string $sku, array $body): Product +{ + // ... +} +``` + +If the final parameter is a class and JMS Serializer is installed, Router automatically deserializes JSON into that class: + +```php +public function post(Product $product): Product +{ + // ... +} +``` + +Class-typed automatic deserialization accepts `application/json` and `application/*+json`. Unsupported or missing media types return 415, and malformed JSON returns 400. + +A controller may implement `_deserialize(string $body)` for non-class, non-array body handling. + +## Middleware + +Controller interception uses `#[Objectiveweb\Router\Middleware]` attributes. Legacy controller methods such as `before()` and `beforePost()` are not invoked in v3. + +See [middleware documentation](../docs/middleware.md). + +## Templates + +If a controller returns an array, Router looks for a PHP template using the controller path and selected method. + +The default template root is: + +```text +/templates +``` + +Router tries the selected controller method template first and the HTTP-method template second. If no template exists, the array continues as a JSON-capable response. + +When both a template and JSON representation are available, `Accept` negotiation chooses between `text/html` and `application/json`. + +Templates created through Router have access to the owning Router's URL generator: + +```php +Products +``` + +## Errors + +If no controller method matches, Router raises a 404 response. Exceptions and PHP Errors raised while resolving or executing controllers and middleware are handled by the route Throwable boundary. diff --git a/docs/middleware.md b/docs/middleware.md new file mode 100644 index 0000000..c608ecf --- /dev/null +++ b/docs/middleware.md @@ -0,0 +1,290 @@ +# Middleware + +Objectiveweb Router uses middleware attributes to intercept controller execution. + +Middleware may implement `Objectiveweb\Router\MiddlewareInterface` when it provides both hooks. The router invokes a hook only when that method exists, so a middleware class may also implement only `before()` or only `after()`. + +## Interface + +```php + [ + TracingMiddleware::class => ['http'], + ], +]); +``` + +or append definitions explicitly: + +```php +$router->addRequestMiddleware(TracingMiddleware::class, ['http']); +``` + +Constructor arguments are passed to Dice through `Router::create()`, so normal DI rules and shared dependencies continue to work. + +For a controller request that produces a response, execution order is: + +1. Request middleware `before()` hooks in declaration order. +2. Controller middleware `before()` hooks in declaration order. +3. Controller method. +4. Controller middleware `after()` hooks in reverse order. +5. Request middleware `after()` hooks in reverse order. +6. `Router::respond()`. + +If the callback/controller throws, request middleware `after()` hooks are not run; the Throwable is handled by Router's normal error boundary. Hard termination through `respond()`, `redirect()`, or `exit()` also skips remaining hooks. + +A class may provide only `before()` or only `after()`; Router checks for each hook before invoking it. Implement `RequestMiddlewareInterface` when both hooks are provided. + +### CORS + +`Objectiveweb\Router\CorsMiddleware` is a built-in request middleware. The existing convenience API now registers this middleware rather than using controller-specific CORS logic: + +```php +$router->setCors('https://app.example'); +``` + +Equivalent explicit registration: + +```php +$router->addRequestMiddleware( + \Objectiveweb\Router\CorsMiddleware::class, + ['https://app.example'] +); +``` + +CORS headers are emitted before controller resolution, so they also apply to controller errors. An OPTIONS request carrying both `Origin` and `Access-Control-Request-Method` is treated as a CORS preflight, receives the configured CORS headers, and terminates with HTTP 204 before controller resolution. + +The CORS middleware constructor also accepts optional credentials, allowed methods, allowed request headers, and exposed response headers. When the allowed origin is `*`, credentials are automatically disabled because browsers reject `Access-Control-Allow-Origin: *` together with `Access-Control-Allow-Credentials: true`: + + +```php +$router->addRequestMiddleware( + \Objectiveweb\Router\CorsMiddleware::class, + [ + 'https://app.example', + true, + ['GET', 'POST', 'OPTIONS'], + ['Authorization', 'Content-Type'], + ['content-range'], + ] +); +``` + +## Execution order + +Middleware definitions are combined in this precedence order: + +1. Router defaults configured through `middlewares`. +2. Controller class attributes. +3. Controller method attributes. + +A narrower scope replaces broader middleware of the same class. Repeated middleware using the same class at the winning scope is preserved in declaration order. + +For the final middleware list: + +1. `before()` hooks run in declaration order. +2. The controller method runs. +3. `after()` hooks run in reverse order. + +This gives normal middleware unwinding around the controller response. + +## Terminating a request + +Middleware has three supported control-flow patterns: + +1. **Continue normally.** Return the complete controller argument array from `before()`. +2. **Reject the request.** Throw an exception with an HTTP status code. The exception remains inside Router's Throwable boundary and is converted to the negotiated error response. +3. **Terminate immediately.** Call a terminating response helper such as `Router::respond()` or `$router->redirect()`, or call `exit()` directly. + +Example authorization guard: + +```php +public function before(string $method, string $fn, array $params): array +{ + if (!$this->auth->check()) { + throw new AuthException('Authentication required', 401); + } + + return $params; +} +``` + +Use exceptions for request failures such as authentication, authorization, validation, rate limiting, and other error conditions. This keeps the failure inside the Router response pipeline, including status handling and content negotiation. + +Example hard termination: + +```php +public function before(string $method, string $fn, array $params): array +{ + if ($this->shouldRedirect()) { + header('Location: /login', true, 302); + exit(''); + } + + return $params; +} +``` + +If middleware already has access to the application Router instance, it may use `$router->redirect()` instead. + +A hard termination ends request processing immediately. The controller is not called, remaining middleware does not run, and `after()` hooks are not executed, including hooks from middleware whose `before()` already ran. + +This is appropriate for truly terminal flows such as redirects or a CORS preflight response. If cleanup, logging, or response transformation must happen after the decision, use normal Router control flow instead of `exit()`. + +`before()` does not return a response value. Its return value is always the controller argument array. Successful early responses that need normal middleware unwinding are not modeled by the v3 middleware contract. + +## Example + +```php +channel] $method $fn"); + + return $params; + } + + public function after( + string $method, + string $fn, + array $params, + mixed $response + ): mixed { + error_log("[$this->channel] completed $method $fn"); + + return $response; + } +} + +#[Middleware(LoggingMiddleware::class, 'products')] +class ProductsController +{ + #[Middleware(LoggingMiddleware::class, 'products.index')] + public function index(array $query): array + { + return ['ok' => true]; + } +} +``` + +In this example, the method-level `LoggingMiddleware` replaces the class-level instance because both use the same middleware class. + +## Default middleware + +Middleware can also be configured when the router is created: + +```php +$router = new \Objectiveweb\Router(null, [ + 'middlewares' => [ + AuthenticationMiddleware::class => [], + LoggingMiddleware::class => ['http'], + ], +]); +``` + +Class- or method-level attributes for the same middleware class replace that default definition. + +## Errors and dependency injection + +Middleware is constructed through the same Dice container used for controllers, so constructor dependencies can be injected normally. + +Exceptions and PHP Errors raised by middleware remain inside the route Throwable boundary and are converted to controlled HTTP responses. 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 +} diff --git a/example/App/ProductsController.php b/example/App/ProductsController.php index 4457619..0ba745e 100644 --- a/example/App/ProductsController.php +++ b/example/App/ProductsController.php @@ -2,119 +2,85 @@ namespace App; +use App\DB\ProductsRepository; use App\Model\Product; -class ProductsController { - - private $name; - - // emulate authentication for tests - public $auth = false; - - // ProductsRepository will be injected automatically - // $name is a random parameter to demonstrate additional parameters - function __construct(\App\DB\ProductsRepository $products, $name = "Products Controller") { - $this->products = $products; - $this->name = $name; - } - - /** - * (optional) runs before every request - */ - function before() { - if(isset($_GET['error'])) { - // trigger an error (could be testing for auth, permissions, etc) - throw new \Exception("error trigger detected", 500); +class ProductsController +{ + public function __construct( + private ProductsRepository $products, + private string $name = 'Products Controller' + ) { } - } - - /** - * (optional) triggered before every POST request - * - * You can also use beforeGet(), beforePut(), beforeDelete() and so on - * Important: before() will also be called before these methods - */ - function beforePost() { - if(!$this->auth) { - throw new \Exception("Unauthorized", 403); + + /** + * GET / + */ + public function index(): array + { + return $this->products->index(); + } + + /** + * GET /sku + */ + public function get($sku): Product + { + return $this->products->get($sku); + } + + /** + * POST / + * + * JMS Serializer can deserialize a JSON body into Product when installed. + */ + public function post(Product $product): Product + { + $this->products->post($product); + + return $product; + } + + /** + * PUT /sku + */ + public function put($sku, array $data): Product + { + $product = $this->get($sku); + + foreach ($data as $key => $value) { + $product->$key = $value; + } + + return $product; + } + + /** + * GET /sale resolves to getSale() before sale(). + */ + public function getSale(): array + { + return $this->sale(90); } - } - - // rest callbacks - - /** - * GET / - */ - function index() { - return $this->products->index(); - } - - /** - * GET /sku - */ - function get($sku) { - return $this->products->get($sku); - } - - /** - * POST / Example - * - * You may also handle other methods defining each function (put, patch, options, head, ...) - */ - function post(\App\Model\Product $product) { - $this->products->post($product); - - return $product; - } - - function put($sku, array $data) { - $product = $this->get($sku); - if(!$product) { - throw new \Exception("Product not found!", 404); - } - - foreach($data as $k => $v) { - $product->$k = $v; - } - - return $product; - } - - /** - * This function will always override sale() for GET requests - * The sale() function will act as a fallback for non-defined method (i.e. VIEW /products/sale) - */ - function getSale() { - return $this->sale(90); - } - - /** - * Handles requests to /sale - */ - function sale($price = 12345) { - $products = $this->products->index(); - $products[0]->price = $price; - - return $products; - } - - /** - * Handles a HEAD /sale request - */ - function headSale() { - header("X-Sale: true"); - return ""; - } - - /** - * Handles an OPTIONS /sale request - */ - function optionsSale() { - return $this->products->count(); - } - - - function hello() { + + /** + * Fallback custom method, e.g. VIEW /sale/50. + */ + public function sale($price = 12345): array + { + $products = $this->products->index(); + $products[0]->price = $price; + + return $products; + } + + public function optionsSale(): int + { + return $this->products->count(); + } + + public function hello(): string + { return "Hello $this->name"; } -} \ No newline at end of file +} diff --git a/example/app-run.php b/example/app-run.php index 2c38253..a8e6677 100644 --- a/example/app-run.php +++ b/example/app-run.php @@ -1,34 +1,27 @@ addRule('App\DB\ProductsRepository', [ 'shared' => true, 'constructParams' => [ - array( - array('name' => "Cassete Recorder", 'sku' => 1, 'price' => 100.00), - array('name' => "Tractor Beam", 'sku' => 2, 'price' => 7.99) - ) - ] + [ + new Product(1, 'Cassette Recorder', 100.00), + new Product(2, 'Tractor Beam', 7.99), + ], + ], ]); - -// Starts the application on the App namespace -// Requests to /products will be mapped to App\ProductsController -// Root and other requests are mapped to App\HomeController -$app->run('App'); \ No newline at end of file +// Requests to /products map to App\ProductsController. +// Root and unmatched controller names fall back to App\HomeController. +$app->run('App'); diff --git a/example/index.php b/example/index.php index 2ee22e2..18232de 100644 --- a/example/index.php +++ b/example/index.php @@ -1,71 +1,55 @@ addRule('App\DB\ProductsRepository', [ 'shared' => true, 'constructParams' => [ - array( - array('name' => "Cassete Recorder", 'sku' => 1, 'price' => 100.00), - array('name' => "Tractor Beam", 'sku' => 2, 'price' => 7.99) - ) - ] + [ + new Product(1, 'Cassette Recorder', 100.00), + new Product(2, 'Tractor Beam', 7.99), + ], + ], ]); -$app->GET("/", function() { - return <<< EOF - - -

Router Example page

- -

ProductsController

-
    -
  • Index: Products listing (json)
  • -
  • Path variable: Product detail
  • -
  • Custom method: Products with 10% discount
  • -
  • Custom method with path parameters: Products with 50% discount
  • -
  • before(): Check error trigger
  • -EOF; +$app->GET('/?', function (array $query) { + return <<<'HTML' + + + +

    Router example

    + +

    ProductsController

    + + + +HTML; }); -/** - * Router::controller will bind a path to a class, using the following schema - * - * GET / => $controller->index(); - * POST / => $controller->post($decoded_post_body); - * PUT / => $controller->put($decoded_post_body); - * PATCH / => $controller->patch($decoded_post_body); - * - * Path parameters - * - * GET|PATCH|POST|PUT|DELETE /path[/path1/path2/...] - * if $controller->path() exists, calls $controller->path($path1, $path2, ...) - * - * When $controller->path() does not exist +/* + * Controller mapping examples: * - * GET /path[/path1/path2/...] - * calls $controller->get($path, path1, $path1, ..., $_GET); - * DELETE /path[/path1/path2/...] - * calls $controller->delete($path, path1, $path1, ..., $_GET); - * POST /path[/path1/path2/...] - * $controller->post($path, path1, $path1, ..., $decoded_post_body); - * PUT /path[/path1/path2/...] - * calls $controller->put($path, path1, $path1, ..., $decoded_post_body); - * PATCH /path[/path1/path2/...] - * calls $controller->patch($path, path1, $path1, ..., $decoded_post_body); + * GET /products -> index($_GET) + * GET /products/1 -> get('1', $_GET) + * POST /products -> post($body) + * PUT /products/1 -> put('1', $body) + * GET /products/sale -> getSale($_GET), then sale($_GET), then get('sale', $_GET) * - * Additional parameters are passed to the class constructor + * Additional arguments passed to controller() are available to the + * controller constructor through Dice. */ -$app->controller("/products", 'App\ProductsController', "Custom Name"); \ No newline at end of file +$app->controller('/products', App\ProductsController::class, 'Custom Name'); diff --git a/src/Router.php b/src/Router.php index 2e5da91..c7db137 100644 --- a/src/Router.php +++ b/src/Router.php @@ -2,30 +2,207 @@ namespace Objectiveweb; -use JMS\Serializer\SerializationContext; +use Objectiveweb\Router\CorsMiddleware; +use Objectiveweb\Router\Middleware; +use Objectiveweb\Router\Template; -class Router extends \Dice\Dice +class Router { - private static $serializers = []; + private static array $serializers = []; - private $cors = null; + private \Dice\Dice $dice; + private array $requestMiddlewares = []; + private array $activeRequestMiddlewares = []; + private bool $requestMiddlewaresStarted = false; + private bool $requestMiddlewaresFinished = false; - function setCors($cors) + public function __construct(?string $_root = null, private array $config = []) { - $this->cors = $cors; + $this->dice = new \Dice\Dice(); + + // By default, use the Composer root package (the application root). + // Fall back to ../../../../ from vendor/objectiveweb/router/src. + if (!$_root) { + if (class_exists(\Composer\InstalledVersions::class)) { + $rootPackage = \Composer\InstalledVersions::getRootPackage(); + $_root = $rootPackage['install_path'] ?? null; + } + + $_root ??= dirname(dirname(dirname(dirname(__DIR__)))); + } + + $defaults = [ + 'debug' => false, + 'request.middlewares' => [], + 'middlewares' => [], + 'trusted.proxies' => [], + 'trusted.hosts' => [], + 'template.root' => $_root . '/templates', + 'template.layout' => null, + ]; + + $this->config = array_merge($defaults, $config); + + if (!is_bool($this->config['debug'])) { + throw new \InvalidArgumentException('debug must be a boolean'); + } + + if (!is_array($this->config['trusted.proxies'])) { + throw new \InvalidArgumentException('trusted.proxies must be an array'); + } + + foreach ($this->config['trusted.proxies'] as $proxy) { + if (!is_string($proxy) || !static::isValidProxyRange($proxy)) { + throw new \InvalidArgumentException( + sprintf('Invalid trusted proxy address or CIDR: %s', is_scalar($proxy) ? (string) $proxy : get_debug_type($proxy)) + ); + } + } + + if ($this->config['trusted.hosts'] !== '*' && !is_array($this->config['trusted.hosts'])) { + throw new \InvalidArgumentException('trusted.hosts must be an array or "*"'); + } + + if (is_array($this->config['trusted.hosts'])) { + foreach ($this->config['trusted.hosts'] as $host) { + if (!is_string($host) || !static::isValidTrustedHost($host)) { + throw new \InvalidArgumentException( + sprintf( + 'Invalid trusted host: %s', + is_scalar($host) ? (string) $host : get_debug_type($host) + ) + ); + } + } + } + + foreach ($this->config['request.middlewares'] as $class => $args) { + if (is_int($class)) { + if (!is_string($args)) { + throw new \InvalidArgumentException( + 'List-style request middleware definitions must be class names' + ); + } + + $this->addRequestMiddleware($args); + continue; + } + + $this->addRequestMiddleware($class, $args); + } + } + + public function setCors(string $origin): void + { + $this->requestMiddlewares = array_values(array_filter( + $this->requestMiddlewares, + static fn (array $definition): bool => $definition['class'] !== CorsMiddleware::class + )); + + $this->addRequestMiddleware(CorsMiddleware::class, [$origin]); + } + + public function addRequestMiddleware(string $class, array $args = []): void + { + $this->requestMiddlewares[] = [ + 'class' => $class, + 'args' => $args, + ]; + } + + private function startRequestMiddlewares(string $method, string $path): void + { + if ($this->requestMiddlewaresStarted) { + return; + } + + $this->requestMiddlewaresStarted = true; + + foreach ($this->requestMiddlewares as $definition) { + $middleware = $this->create( + $definition['class'], + $definition['args'] + ); + + if (method_exists($middleware, 'before')) { + $middleware->before($method, $path); + } + + $this->activeRequestMiddlewares[] = $middleware; + } + } + + private function finishRequestMiddlewares( + string $method, + string $path, + mixed $response + ): mixed { + if ($this->requestMiddlewaresFinished) { + return $response; + } + + foreach (array_reverse($this->activeRequestMiddlewares) as $middleware) { + if (method_exists($middleware, 'after')) { + $response = $middleware->after($method, $path, $response); + } + } + + $this->requestMiddlewaresFinished = true; + + return $response; } - static function addSerializer($type, $callback) + public static function addSerializer(string $type, callable $callback): void { self::$serializers[$type] = $callback; } - static function hasSerializer($type) + public static function hasSerializer(string $type): bool { return !empty(self::$serializers[$type]); } + public function addRule(string $name, array $rule): void + { + $this->dice = $this->dice->addRule($name, $rule); + } + + public function create(string $name, array $args = []): object + { + return $this->dice->create($name, $args); + } + + /** + * Return a new Template() object based on default root and optional layout + * + * @param $names + * @param array|null $_data + * @param string|null $layout + * @return Template|null + * @throws \Exception + */ + public function template(string|array $names, ?array $_data = null, ?string $_layout = null): ?Template + { + $_root = $this->config["template.root"]; + $_layout = $_layout ?? $this->config["template.layout"]; + $_data = $_data ?? []; + + if (is_array($names)) { + foreach ($names as $name) { + if (is_readable($_root . DIRECTORY_SEPARATOR . $name . '.php')) { + return new Template($_root, $name, $_data, $_layout, $this); + } + } + } else { + if (is_readable($_root . DIRECTORY_SEPARATOR . $names . '.php')) { + return new Template($_root, $names, $_data, $_layout, $this); + } + } + + return null; + } + /** * Route a particular request to a callback * @@ -33,105 +210,155 @@ static function hasSerializer($type) * @param $request - HTTP Request Method + Request-URI Regex e.g. "GET /something/([0-9]+)/?" * @param $callback - A valid callback. Regex capture groups are passed as arguments to this function, using * array('Namespace\ClassNameAsString', 'method') triggers the dependency injector to instantiate the given class - * @return void or data - If the callback returns something, it's responded accordingly, otherwise, nothing happens + * If the callback returns a value, Router sends it through the response pipeline. * @throws \Exception */ - 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); - } + public function route( + string $request, + mixed $callback, + mixed ...$args + ): void { + $this->dispatchRoute( + $request, + $callback, + $args + ); + } + /** + * Match and execute a route through the common callback/error boundary. + * + * $argumentFactory is used by HTTP verb helpers so request-derived + * arguments such as query parameters and decoded bodies are created only + * after the route matches and inside the Throwable boundary. + */ + private function dispatchRoute( + string $request, + $callback, + array $extraArgs = [], + ?callable $argumentFactory = null + ): void { // support PATH_INFO when using mod_rewrite if (empty($_SERVER['REDIRECT_URL'])) { $_SERVER['REDIRECT_URL'] = preg_replace('/\?.*$/', '', $_SERVER['REQUEST_URI']); } - $p = sprintf('/%s(\\/%s)?(.*)/', - str_replace('/', '\\/', dirname($_SERVER['SCRIPT_NAME'])), - str_replace('.', '\\.', basename($_SERVER['SCRIPT_NAME'])) + $p = sprintf('/%s(\/%s)?(.*)/', + str_replace('/', '\/', dirname($_SERVER['SCRIPT_NAME'])), + str_replace('.', '\.', basename($_SERVER['SCRIPT_NAME'])) ); 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]; } + $method = (string) $_SERVER['REQUEST_METHOD']; + $path = (string) $_SERVER['PATH_INFO']; + + // Route execution is the HTTP error boundary. Request middleware, + // route matching, callback resolution, dependency injection, request + // argument preparation, invocation and response preparation can all + // raise PHP Errors or Exceptions. + try { + // Request middleware is global to the incoming request. before() + // runs once, before route matching begins. + $this->startRequestMiddlewares($method, $path); + + if (!preg_match( + sprintf("/^%s$/", str_replace('/', '\/', $request)), + "$method $path", + $params + )) { + return; + } - if (preg_match(sprintf("/^%s$/", str_replace('/', '\/', $request)), "{$_SERVER['REQUEST_METHOD']} {$_SERVER['PATH_INFO']}", $params)) { array_shift($params); - if (func_num_args() > 2) { - $params = array_merge($params, array_slice(func_get_args(), 2)); + // A [ClassName::class, 'method'] callback is resolved through Dice. + if (is_array($callback) && is_string($callback[0])) { + $callback[0] = $this->create($callback[0], $params); } - 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 { - self::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()); - } - self::respond(['exception' => get_class($ex), 'message' => $ex->getMessage()], $ex->getCode()); + if (!is_callable($callback)) { + $callbackType = is_string($callback) ? $callback : get_debug_type($callback); + throw new \RuntimeException( + sprintf(_('%s: Invalid callback'), $callbackType), + 500 + ); + } + + if ($argumentFactory !== null) { + $resolvedArgs = $argumentFactory(); + if (!is_array($resolvedArgs)) { + throw new \UnexpectedValueException( + 'Route argument factory must return an array', + 500 + ); } + + $extraArgs = array_merge($extraArgs, $resolvedArgs); } - } - } - /** - * Runs $callable with arguments if it's callable, otherwise, does nothing - * @param $callable - * @return mixed|null - */ - private function _call($callable) - { - if (is_callable($callable)) { - $args = func_get_args(); - array_shift($args); - return call_user_func_array($callable, $args); - } + $params = array_merge($params, $extraArgs); - return null; + $response = call_user_func_array($callback, $params); + + if ($response !== NULL) { + $response = $this->finishRequestMiddlewares($method, $path, $response); + static::respond($response, 200, $this->config['debug']); + } + } 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, $status, $this->config['debug']); + } } /** - * Binds a controller get/post/put/destroy or custom functions to HTTP methods + * Binds a controller to HTTP-method or custom action methods. * @param $path String path prefix (/path) * @param $controller mixed class name or class * @param ... mixed passed to controller instantiation * @throws \Exception */ - public function controller($path, $controller) - { - $args = func_get_args(); - array_splice($args, 0, 2); - - $re = sprintf("([A-Z]+) (?:$path$|%s)(.*)", $path == '/' ? '/' : $path . '/'); + public function controller( + string $path, + object|string $controller, + mixed ...$args + ): void { + $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(); + 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); } - $method = strtolower($method); + $requestMethod = strtolower($method); + $method = $requestMethod === 'head' ? 'get' : $requestMethod; + + // HEAD uses GET controller resolution while middleware still sees + // the actual request method. // 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 ] @@ -160,44 +387,48 @@ public function controller($path, $controller) } if (!is_callable(array($controller, $fn))) { - if ($this->cors && $fn == 'options' - && isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD']) - && isset($_SERVER['HTTP_ORIGIN'])) { - - header("Access-Control-Allow-Origin: $this->cors"); - header("Access-Control-Allow-Credentials: true"); - header("Access-Control-Allow-Methods: GET, PATCH, POST, PUT, DELETE, OPTIONS"); - if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) - header("Access-Control-Allow-Headers: {$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']}"); - - exit(""); - } - throw new \Exception(sprintf(_("%s\\%s: Route not found"), get_class($controller), $fn), 404); } - if ($this->cors) { - header("Access-Control-Allow-Origin: $this->cors"); - header("Access-Control-Allow-Credentials: true"); - 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')) { + $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'))) { @@ -213,24 +444,104 @@ public function controller($path, $controller) break; } - // Process controller.before - $p = $this->_call([$controller, 'before'], $method, $fn, $params); + // Build middleware definitions while preserving repeated attributes. + // A narrower scope replaces broader middleware of the same class: + // defaults < class attributes < method attributes. + $middlewareDefinitions = []; + foreach ($this->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; + } + + if ($classMiddlewareClasses) { + $middlewareDefinitions = array_values(array_filter( + $middlewareDefinitions, + static fn (array $definition): bool => !isset($classMiddlewareClasses[$definition['class']]) + )); + } + + foreach ($classAttributes as $attr) { + $definition = $attr->newInstance(); + $middlewareDefinitions[] = [ + 'class' => $definition->getClass(), + 'args' => $definition->getArgs(), + ]; + } - if ($p) { - $params = $p; + $methodAttributes = $refMethod->getAttributes(Middleware::class, \ReflectionAttribute::IS_INSTANCEOF); + $methodMiddlewareClasses = []; + foreach ($methodAttributes as $attr) { + /** @var Middleware $definition */ + $definition = $attr->newInstance(); + $methodMiddlewareClasses[$definition->getClass()] = true; } - // Process controller.before[Post|Get|Put|Delete|...] - $p = $this->_call([$controller, 'before' . ucfirst($method)], $fn, $params); + if ($methodMiddlewareClasses) { + $middlewareDefinitions = array_values(array_filter( + $middlewareDefinitions, + static fn (array $definition): bool => !isset($methodMiddlewareClasses[$definition['class']]) + )); + } - if ($p) { - $params = $p; + foreach ($methodAttributes as $attr) { + $definition = $attr->newInstance(); + $middlewareDefinitions[] = [ + 'class' => $definition->getClass(), + 'args' => $definition->getArgs(), + ]; } - $response = call_user_func_array(array($controller, $fn), $params); + // Instantiate and execute before() hooks in declaration order. + $middlewares = []; + foreach ($middlewareDefinitions as $definition) { + $mw = $this->create( + $definition['class'], + $definition['args'] + ); + + if (method_exists($mw, 'before')) { + $updatedParams = call_user_func([$mw, 'before'], $requestMethod, $fn, $params); + if (!is_array($updatedParams)) { + throw new \UnexpectedValueException(sprintf( + '%s::before() must return an array', + get_class($mw) + ), 500); + } - // if client wants json, return right away - response will be encoded by route() and respond() - if (strpos($_SERVER['HTTP_ACCEPT'], 'json')) { + $params = $updatedParams; + } + + $middlewares[] = $mw; + } + + // 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 implemented after() hooks in reverse order. + foreach (array_reverse($middlewares) as $mw) { + if (method_exists($mw, 'after')) { + $response = call_user_func([$mw, 'after'], $requestMethod, $fn, $params, $response); + } + } + + // 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)) { return $response; } @@ -238,27 +549,30 @@ 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_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"]); - - foreach ($templates as $template) { - if (is_readable($template)) { - if (is_readable("$template_root/_functions.php")) { - include "$template_root/_functions.php"; - } + $templates = array_unique(["$template_path$fn", "$template_path$method"]); + $template = $this->template($templates, $response); - return Router::render($template, $response); - } + if (!$template) { + return $response; } - // in case no template is available, return the response - 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; }); } @@ -269,18 +583,13 @@ public function controller($path, $controller) * @param callable $callback function(match[1], match[2], ..., $_GET) * @throws \Exception */ - public function DELETE($path, $callback) + public function DELETE(string $path, mixed $callback): void { - if (!is_callable($callback)) { - throw new \Exception(sprintf(_('%s: Invalid callback'), $callback), 500); - } - - $this->route("DELETE $path", function () use ($callback) { - $args = func_get_args(); - $args[] = $_GET; - - return call_user_func_array($callback, $args); - }); + $this->dispatchRoute( + "DELETE $path", + $callback, + argumentFactory: static fn (): array => [$_GET] + ); } /** @@ -290,18 +599,13 @@ public function DELETE($path, $callback) * @param callable $callback function(match[1], match[2], ..., $_GET) * @throws \Exception */ - public function GET($path, $callback) + public function GET(string $path, mixed $callback): void { - if (!is_callable($callback)) { - throw new \Exception(sprintf(_('%s: Invalid callback'), $callback), 500); - } - - $this->route("GET $path", function () use ($callback) { - $args = func_get_args(); - $args[] = $_GET; - - return call_user_func_array($callback, $args); - }); + $this->dispatchRoute( + "(?:GET|HEAD) $path", + $callback, + argumentFactory: static fn (): array => [$_GET] + ); } /** @@ -311,18 +615,13 @@ public function GET($path, $callback) * @param callable $callback function(match[1], match[2], ..., <$post_body>) * @throws \Exception */ - public function POST($path, $callback) + public function POST(string $path, mixed $callback): void { - if (!is_callable($callback)) { - throw new \Exception(sprintf(_('%s: Invalid callback'), $callback), 500); - } - - $this->route("POST $path", function () use ($callback) { - $args = func_get_args(); - $args[] = Router::parse_post_body(); - - return call_user_func_array($callback, $args); - }); + $this->dispatchRoute( + "POST $path", + $callback, + argumentFactory: static fn (): array => [Router::parse_post_body()] + ); } /** @@ -332,24 +631,35 @@ public function POST($path, $callback) * @param callable $callback function(match[1], match[2], ..., <$post_body>) * @throws \Exception */ - public function PUT($path, $callback) + public function PUT(string $path, mixed $callback): void { - if (!is_callable($callback)) { - throw new \Exception(sprintf(_('%s: Invalid callback'), $callback), 500); - } - - $this->route("PUT $path", function () use ($callback) { - $args = func_get_args(); - $args[] = Router::parse_post_body(); + $this->dispatchRoute( + "PUT $path", + $callback, + argumentFactory: static fn (): array => [Router::parse_post_body()] + ); + } - return call_user_func_array($callback, $args); - }); + /** + * Matches a PATCH request, + * Callback is called with regex matches + decoded request body + * @param $path + * @param callable $callback function(match[1], match[2], ..., <$request_body>) + * @throws \Exception + */ + public function PATCH(string $path, mixed $callback): void + { + $this->dispatchRoute( + "PATCH $path", + $callback, + argumentFactory: static fn (): array => [Router::parse_post_body()] + ); } /** * Bootstraps an endpoint based on $namespace */ - public function run($namespace) + public function run(string $namespace): void { $router = $this; @@ -370,92 +680,400 @@ public function run($namespace) } /** - * Constructs an URL for a given path - * - If the given url is external or exists as a file on disk, return that file's url - * - If the file does not exist, construct a url based on the current script + path info - * - If portions of the path exist, treat the rest as parameters (point to another controller) - * - * If the given path is NULL, returns the current url with protocol, port and so on - * - * Examples - * url('css/style.css'); returns '/some_root/my_application/css/style.css' - * url('1'); returns '/some_root/my_application/controller.php/1' (if we ran that command from controller.php) - * url('othercontroller.php/1/2'); returns '/some_root/my_application/othercontroller.php/1/2' (if othercontroller.php exists) + * Construct a URL for the current request or for a path relative to the + * current script. * - * @param $str - * @return string + * NULL, an empty string, and "self" return the absolute current URL, + * including the request scheme, host, non-default port, and script URL. + * Other values are appended to the current script directory. When both + * SCRIPT_URL and PATH_INFO are available, PATH_INFO is removed from + * SCRIPT_URL so generated paths remain anchored to the front controller. */ - public static function url($str = null) + public function url(?string $str = null): string { - if ($str == 'self' || empty($str)) { + if ($str === 'self' || $str === null || $str === '') { + $trustedProxy = $this->isTrustedProxy($_SERVER['REMOTE_ADDR'] ?? ''); + + $protocol = $this->requestProtocol(); + $hostHeader = $_SERVER['SERVER_NAME'] ?? 'localhost'; + $port = isset($_SERVER['SERVER_PORT']) ? (int) $_SERVER['SERVER_PORT'] : null; + + $directHost = $_SERVER['HTTP_HOST'] ?? null; + if (is_string($directHost) && $this->isTrustedHost($directHost)) { + $hostHeader = $directHost; + } + + $forwardedProto = null; + $forwardedHost = null; + $forwardedPort = null; + + if ($trustedProxy) { + $candidateProto = static::forwardedHeader('HTTP_X_FORWARDED_PROTO'); + if ( + $candidateProto !== null + && in_array(strtolower($candidateProto), ['http', 'https'], true) + ) { + $forwardedProto = strtolower($candidateProto); + $protocol = $forwardedProto; + } + + $candidateHost = static::forwardedHeader('HTTP_X_FORWARDED_HOST'); + if ($candidateHost !== null && static::isValidHostHeader($candidateHost)) { + $forwardedHost = $candidateHost; + $hostHeader = $forwardedHost; + } + + $candidatePort = static::forwardedHeader('HTTP_X_FORWARDED_PORT'); + if ($candidatePort !== null && ctype_digit($candidatePort)) { + $candidatePortNumber = (int) $candidatePort; + if ($candidatePortNumber >= 1 && $candidatePortNumber <= 65535) { + $forwardedPort = $candidatePortNumber; + } + } + } + + [$host, $hostPort] = static::splitHostAndPort($hostHeader); + + if ($forwardedPort !== null) { + $port = $forwardedPort; + } elseif ($forwardedHost !== null && $hostPort !== null) { + $port = $hostPort; + } elseif ($forwardedProto !== null) { + // A proxy that supplies the external scheme but no explicit + // external port is assumed to use that scheme's default port. + $port = $protocol === 'https' ? 443 : 80; + } elseif ($hostPort !== null) { + $port = $hostPort; + } + + $url = $protocol . '://' . $host; + if ( - isset($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] == 'on' || $_SERVER['HTTPS'] == 1) - || isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https' + $port !== null + && !(($protocol === 'http' && $port === 80) || ($protocol === 'https' && $port === 443)) ) { - $protocol = 'https://'; + $url .= ':' . $port; + } + + $url .= !empty($_SERVER['SCRIPT_URL']) + ? $_SERVER['SCRIPT_URL'] + : ($_SERVER['PHP_SELF'] ?? $_SERVER['SCRIPT_NAME'] ?? '/'); + + return $url; + } + + if (!empty($_SERVER['PATH_INFO'])) { + if (!empty($_SERVER['SCRIPT_URL'])) { + $path = substr($_SERVER['SCRIPT_URL'], 0, -1 * strlen($_SERVER['PATH_INFO'])); } else { - $protocol = 'http://'; + $path = dirname($_SERVER['SCRIPT_NAME']); } + } else { + $path = dirname($_SERVER['SCRIPT_NAME']); + } - $url = $protocol . $_SERVER['HTTP_HOST']; + return ($path === '/' ? '' : $path) . ($str[0] === '/' ? $str : '/' . $str); + } - // use port if non default - $port = isset($_SERVER['HTTP_X_FORWARDED_PORT']) - ? $_SERVER['HTTP_X_FORWARDED_PORT'] - : (isset($_SERVER['SERVER_PORT']) ? $_SERVER['SERVER_PORT'] : ''); - $url .= - (($protocol === 'http://' && $port != 80) || ($protocol === 'https://' && $port != 443)) - ? ':' . $port - : ''; + private function isTrustedHost(string $hostHeader): bool + { + if (!static::isValidHostHeader($hostHeader)) { + return false; + } - $url .= !empty($_SERVER['SCRIPT_URL']) ? $_SERVER['SCRIPT_URL'] : $_SERVER['PHP_SELF']; + if ($this->config['trusted.hosts'] === '*') { + return true; + } - // return current url - return $url; - } else { + [$host] = static::splitHostAndPort($hostHeader); + $host = static::normalizeHostForComparison($host); - if (!empty($_SERVER['PATH_INFO'])) { - if (!empty($_SERVER['SCRIPT_URL'])) { - $PATH = substr($_SERVER['SCRIPT_URL'], 0, -1 * strlen($_SERVER['PATH_INFO'])); - } else { - $PATH = dirname($_SERVER['SCRIPT_NAME']); - } - } else { - $PATH = dirname($_SERVER['SCRIPT_NAME']); + foreach ($this->config['trusted.hosts'] as $trustedHost) { + [$candidate] = static::splitHostAndPort($trustedHost); + + if ($host === static::normalizeHostForComparison($candidate)) { + return true; } + } - return ($PATH == '/' ? '' : $PATH) . ($str[0] == '/' ? $str : '/' . $str); + return false; + } + + private static function normalizeHostForComparison(string $host): string + { + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $host = substr($host, 1, -1); } + + $packed = @inet_pton($host); + if ($packed !== false) { + return bin2hex($packed); + } + + return strtolower(rtrim($host, '.')); } - public static function parse_post_body($decoded = true, $as_array = true) + private function requestProtocol(): string { + return isset($_SERVER['HTTPS']) + && ($_SERVER['HTTPS'] === 'on' || $_SERVER['HTTPS'] === '1' || $_SERVER['HTTPS'] === 1) + ? 'https' + : 'http'; + } - 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; + private function isTrustedProxy(string $remoteAddress): bool + { + if ($remoteAddress === '') { + return false; + } + + foreach ($this->config['trusted.proxies'] as $trustedProxy) { + if (static::addressMatchesRange($remoteAddress, $trustedProxy)) { + return true; + } + } + + return false; + } + + private static function isValidProxyRange(string $range): bool + { + [$network, $prefix] = array_pad(explode('/', $range, 2), 2, null); + $packed = @inet_pton($network); + if ($packed === false) { + return false; + } + + if ($prefix === null) { + return true; + } + + if ($prefix === '' || !ctype_digit($prefix)) { + return false; + } + + $bits = strlen($packed) * 8; + + return (int) $prefix >= 0 && (int) $prefix <= $bits; + } + + private static function addressMatchesRange(string $address, string $range): bool + { + $packedAddress = @inet_pton($address); + if ($packedAddress === false) { + return false; + } + + [$network, $prefix] = array_pad(explode('/', $range, 2), 2, null); + $packedNetwork = @inet_pton($network); + if ($packedNetwork === false || strlen($packedNetwork) !== strlen($packedAddress)) { + return false; + } + + if ($prefix === null) { + return $packedAddress === $packedNetwork; + } + + $prefixBits = (int) $prefix; + $wholeBytes = intdiv($prefixBits, 8); + $remainingBits = $prefixBits % 8; + + if ( + $wholeBytes > 0 + && substr($packedAddress, 0, $wholeBytes) !== substr($packedNetwork, 0, $wholeBytes) + ) { + return false; + } + + if ($remainingBits === 0) { + return true; + } + + $mask = (0xff << (8 - $remainingBits)) & 0xff; + + return (ord($packedAddress[$wholeBytes]) & $mask) + === (ord($packedNetwork[$wholeBytes]) & $mask); + } + + private static function forwardedHeader(string $name): ?string + { + if (!isset($_SERVER[$name])) { + return null; + } + + $value = trim((string) $_SERVER[$name]); + + // Objectiveweb Router intentionally does not interpret proxy chains. + // The trusted proxy must replace forwarded headers with one authoritative value. + if ( + $value === '' + || str_contains($value, ',') + || preg_match('/[\r\n]/', $value) + ) { + return null; + } + + return $value; + } + + private static function isValidTrustedHost(string $host): bool + { + $host = trim($host); + + if ( + $host === '' + || preg_match('/[\s\x00-\x1f\x7f\/\\@?#]/', $host) + ) { + return false; + } + + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $host = substr($host, 1, -1); + } + + if (@inet_pton($host) !== false) { + return true; + } + + return preg_match('/^[A-Za-z0-9._-]+$/', $host) === 1; + } + + private static function isValidHostHeader(string $host): bool + { + if ( + $host === '' + || str_contains($host, ',') + || preg_match('/[\s\x00-\x1f\x7f\/\\@?#]/', $host) + ) { + return false; + } + + if (str_starts_with($host, '[')) { + if (!preg_match('/^(\[[0-9a-fA-F:.]+\])(?::([0-9]+))?$/', $host, $matches)) { + return false; + } + + if ( + isset($matches[2]) + && ((int) $matches[2] < 1 || (int) $matches[2] > 65535) + ) { + return false; + } + } + + [$hostname] = static::splitHostAndPort($host); + + if (str_starts_with($hostname, '[') && str_ends_with($hostname, ']')) { + return @inet_pton(substr($hostname, 1, -1)) !== false; + } + + return preg_match('/^[A-Za-z0-9._-]+$/', $hostname) === 1; + } + + /** + * @return array{0:string,1:?int} + */ + private static function splitHostAndPort(string $hostHeader): array + { + $hostHeader = trim($hostHeader); + + // Bracketed IPv6 literal, optionally followed by a port. + if (str_starts_with($hostHeader, '[')) { + if (preg_match('/^(\[[0-9a-fA-F:.]+\])(?::([0-9]+))?$/', $hostHeader, $matches)) { + return [ + $matches[1], + isset($matches[2]) + && (int) $matches[2] >= 1 + && (int) $matches[2] <= 65535 + ? (int) $matches[2] + : null, + ]; + } + + return [$hostHeader, null]; + } + + if (substr_count($hostHeader, ':') === 1) { + [$host, $port] = explode(':', $hostHeader, 2); + if ($port !== '' && ctype_digit($port)) { + $portNumber = (int) $port; + if ($portNumber >= 1 && $portNumber <= 65535) { + return [$host, $portNumber]; } + } + } + + return [$hostHeader, null]; + } + + private static function requestContentType(): string + { + 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(bool $decoded = true, bool $as_array = true): mixed + { + $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. + $postBody = is_string($_POST) + ? $_POST + : file_get_contents('php://input'); + + if (!$decoded) { + return $postBody; + } + + if (static::isJsonContentType($contentType)) { + return static::decodeJsonBody($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; } /** * 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. @@ -467,69 +1085,336 @@ public static function parse_post_body($decoded = true, $as_array = true) * 307 Temporary Redirect - In this case, the request should be repeated with another URI; however, future requests should still use the original URI. * 308 Permanent Redirect - The request and all future requests should be repeated using another URI. */ - public static function redirect($to, $code = 301) + public function redirect(string $to, int $code = 301): never { header("HTTP/1.1 $code"); - header('Location: ' . Router::url($to)); + + if (preg_match('#^https?://#', $to)) { + header("Location: $to"); + } else { + header('Location: ' . $this->url($to)); + } + exit(); } - public static function respond($content, $code = 200) + /** + * Choose the best representation from the server-supported content types. + * + * 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. + */ + 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)); + + 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( + mixed $content, + ?string $accept = null, + ?int $status = null, + bool $debug = false + ): array + { + if ($content instanceof \Throwable && !self::hasSerializer(get_class($content))) { + $contentType = static::negotiateContentType( + ['text/html', 'application/json'], + $accept + ); + + if ($contentType === null) { + return [ + 'body' => '', + 'content_type' => null, + 'vary_accept' => true, + ]; + } + + $redact = !$debug && $status !== null && $status >= 500; + + if ($contentType === 'text/html') { + if ($redact) { + return [ + 'body' => '

    Internal Server Error

    ', + 'content_type' => 'text/html', + 'vary_accept' => true, + ]; + } + + $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, + ]; + } + + if ($redact) { + return [ + 'body' => static::serializeJson([ + 'message' => 'Internal Server Error', + ]), + 'content_type' => 'application/json', + 'vary_accept' => true, + ]; + } + + return [ + 'body' => static::serializeJson([ + 'exception' => get_class($content), + 'message' => $content->getMessage(), + ]), + 'content_type' => 'application/json', + 'vary_accept' => true, + ]; + } + + $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); - } - } elseif (is_array($content)) { - $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']; } - if (!empty($content) && is_string($content) && ($content[0] == '{' || $content[0] == '[')) { - header('Content-type: application/json'); + $contentType = static::negotiateContentType($available, $accept); + $varyAccept = count($available) > 1; + + if ($contentType === null) { + return [ + 'body' => '', + 'content_type' => null, + 'vary_accept' => $varyAccept, + ]; } - exit($content); + 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, + ]; } - public static function render($_template, $_data = []) + private static function serializeJson(mixed $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() + ); + } + + 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( + mixed $content, + int $code = 200, + ?string $accept = null, + ?string $requestMethod = null, + bool $debug = false + ): array { + // Informational responses, 204, 205 and 304 never carry a message body + // and do not require representation negotiation. + if ( + ($code >= 100 && $code < 200) + || $code === 204 + || $code === 205 + || $code === 304 + ) { + return [ + 'status' => $code, + 'body' => '', + 'content_type' => null, + 'vary_accept' => false, + ]; + } + + $response = static::prepareResponse($content, $accept, $code, $debug); + $status = $response['content_type'] === null ? 406 : $code; + + // HEAD selects the same representation as GET but never emits its body. + if (strcasecmp((string) $requestMethod, 'HEAD') === 0) { + $response['body'] = ''; + } + + return [ + 'status' => $status, + ...$response, + ]; + } - if (!is_readable($_template)) { - throw new \Exception("Cannot read $_template", 404); + public static function respond( + mixed $content, + int $code = 200, + bool $debug = false + ) { + $response = static::prepareHttpResponse( + $content, + $code, + $_SERVER['HTTP_ACCEPT'] ?? null, + $_SERVER['REQUEST_METHOD'] ?? null, + $debug + ); + + if ($response['vary_accept']) { + header('Vary: Accept', false); } - if (is_array($_data)) { - extract($_data); + header("HTTP/1.1 {$response['status']}"); + + if ($response['content_type'] === null) { + exit(''); } - ob_start(); - include $_template; - $contents = ob_get_contents(); - ob_end_clean(); + header('Content-Type: ' . $response['content_type'] . '; charset=utf-8'); - return $contents; + exit($response['body']); } - public static function isAjax() + public static function isAjax(): bool { - return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'; + return isset($_SERVER['HTTP_X_REQUESTED_WITH']) + && strtolower((string) $_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'; } } diff --git a/src/Router/CorsMiddleware.php b/src/Router/CorsMiddleware.php new file mode 100644 index 0000000..631a1e4 --- /dev/null +++ b/src/Router/CorsMiddleware.php @@ -0,0 +1,73 @@ + $methods + * @param array|null $allowHeaders + * @param array $exposeHeaders + */ + public function __construct( + private string $origin, + private bool $credentials = true, + private array $methods = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + private ?array $allowHeaders = null, + private array $exposeHeaders = ['content-range'] + ) { + // Browsers reject wildcard origins when credentials are allowed. + // Keep the common CorsMiddleware('*') / setCors('*') configuration + // valid by automatically disabling credentials. + if ($this->origin === '*') { + $this->credentials = false; + } + } + + public function before(string $method, string $path): void + { + header('Access-Control-Allow-Origin: ' . $this->origin); + + if ($this->credentials) { + header('Access-Control-Allow-Credentials: true'); + } + + if ($this->exposeHeaders !== []) { + header( + 'Access-Control-Expose-Headers: ' . implode(', ', $this->exposeHeaders) + ); + } + + if ( + strtoupper($method) !== 'OPTIONS' + || !isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD']) + || !isset($_SERVER['HTTP_ORIGIN']) + ) { + return; + } + + header( + 'Access-Control-Allow-Methods: ' . implode(', ', $this->methods) + ); + + $allowHeaders = $this->allowHeaders; + if ($allowHeaders === null) { + $requestedHeaders = trim((string) ($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ?? '')); + if ($requestedHeaders !== '') { + header('Access-Control-Allow-Headers: ' . $requestedHeaders); + } + } elseif ($allowHeaders !== []) { + header( + 'Access-Control-Allow-Headers: ' . implode(', ', $allowHeaders) + ); + } + + http_response_code(204); + exit(''); + } + + public function after(string $method, string $path, mixed $response): mixed + { + return $response; + } +} diff --git a/src/Router/Middleware.php b/src/Router/Middleware.php new file mode 100644 index 0000000..e26e14b --- /dev/null +++ b/src/Router/Middleware.php @@ -0,0 +1,28 @@ +args = $args; + } + + public function getClass(): string + { + return $this->class; + } + + public function getArgs(): array + { + return $this->args; + } +} diff --git a/src/Router/MiddlewareInterface.php b/src/Router/MiddlewareInterface.php new file mode 100644 index 0000000..7a512a3 --- /dev/null +++ b/src/Router/MiddlewareInterface.php @@ -0,0 +1,26 @@ +_template = $this->_root . DIRECTORY_SEPARATOR . $_template . '.php'; + + if (!is_readable($this->_template)) { + throw new \Exception("Cannot read $this->_template", 500); + } + } + + public function url(?string $path = null): string + { + if ($this->_router === null) { + throw new \LogicException( + 'Template URL generation requires a Template created by Router::template()' + ); + } + + return $this->_router->url($path); + } + + /** + * Render the template and optional layout. + * + * @throws \Throwable If template or layout execution fails. + */ + public function render(): string + { + $_contents = $this->renderFile($this->_template, $this->_data); + + if ($this->_layout === null) { + return $_contents; + } + + $layout = $this->_root . '/_layouts/' . $this->_layout . '.php'; + if (!is_readable($layout)) { + throw new \Exception("Cannot read $this->_layout", 500); + } + + return $this->renderFile( + $layout, + [ + ...$this->_data, + '_contents' => $_contents, + ] + ); + } + + /** + * Render one PHP file with an isolated output buffer. + * + * Template data cannot overwrite local renderer variables. + */ + private function renderFile(string $__file, array $__data): string + { + extract($__data, EXTR_SKIP); + + $bufferLevel = ob_get_level(); + ob_start(); + + try { + include $__file; + + // If template code opened additional buffers without closing them, + // flush those into our rendering buffer before collecting it. + while (ob_get_level() > $bufferLevel + 1) { + ob_end_flush(); + } + + return (string) ob_get_clean(); + } catch (\Throwable $exception) { + // Restore exactly the buffer depth that existed before rendering. + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } + + throw $exception; + } + } +} diff --git a/test/ControllerTest.php b/test/ControllerTest.php index 11494ab..081e934 100644 --- a/test/ControllerTest.php +++ b/test/ControllerTest.php @@ -6,176 +6,590 @@ 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\ProductsController; use App\Model\Product; +use App\ProductsController; +use PHPUnit\Framework\TestCase; use Test\Router; -class ControllerTest extends PHPUnit_Framework_TestCase +class VariadicController { + public function __construct( + private string $explicit, + private string $account, + private string $region + ) { + } - /** @var ProductsController */ - static protected $controller; + public function index(array $query): array + { + return [ + 'explicit' => $this->explicit, + 'account' => $this->account, + 'region' => $this->region, + 'query' => $query, + ]; + } +} - /** @var Router */ - static protected $app; +class RoutingRegressionController +{ + public function patch(string $id, array $body): array + { + return [ + 'handler' => 'patch', + 'id' => $id, + 'body' => $body, + ]; + } - public static function setUpBeforeClass() + public function optionsStatus(array $query): array { - self::$app = new Router(); - self::$app->addRule('App\DB\ProductsRepository', [ + return [ + 'handler' => 'optionsStatus', + 'query' => $query, + ]; + } + + public function status(array $query): array + { + return [ + 'handler' => 'status', + 'query' => $query, + ]; + } +} + +class ControllerTest extends TestCase +{ + private Router $app; + + protected function setUp(): void + { + $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'], $_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_CONTENT_TYPE']); + + 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; + + $this->app->controller('/', ProductsController::class, 'TEST'); + } + + public function testCreateExposesOnlySupportedDiceArguments(): void + { + $method = new \ReflectionMethod(Router::class, 'create'); - self::$app->controller("/", 'App\ProductsController', 'TEST'); + $this->assertSame(2, $method->getNumberOfParameters()); + $this->assertSame(1, $method->getNumberOfRequiredParameters()); + $this->assertSame('object', (string) $method->getReturnType()); } - public function testIndex() + public function testRawRoutePassesRegexCapturesInOrder(): void { global $response_value; - self::route("GET", "/"); + $_SERVER['PATH_INFO'] = '/raw/books/42'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/raw/books/42'; + $_SERVER['REDIRECT_URL'] = '/raw/books/42'; - $this->assertEquals(2, count($response_value)); - $this->assertEquals(1, $response_value[0]->sku); + $this->app->route( + 'GET /raw/([a-z]+)/([0-9]+)', + static fn (string $category, string $id): array => [ + 'category' => $category, + 'id' => $id, + ] + ); + $this->assertSame([ + 'category' => 'books', + 'id' => '42', + ], $response_value); } - public function testGet() + public function testRouteExplicitArgumentsFollowRegexCaptures(): void { global $response_value; - self::route("GET", "/2"); - $this->assertEquals(2, $response_value->sku); + $_SERVER['PATH_INFO'] = '/items/42'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/items/42'; + $_SERVER['REDIRECT_URL'] = '/items/42'; + + $this->app->route( + 'GET /items/([0-9]+)', + static fn (string $id, string $source, int $limit): array => [ + 'id' => $id, + 'source' => $source, + 'limit' => $limit, + ], + 'inventory', + 25 + ); + + $this->assertSame([ + 'id' => '42', + 'source' => 'inventory', + 'limit' => 25, + ], $response_value); } - public function testBeforePost() + public function testControllerExplicitArgumentsPrecedeRegexCaptures(): void { - global $response_code; - self::route("POST", "/"); + global $response_value; - $this->assertEquals(403, $response_code); + $_GET = ['active' => '1']; + $_SERVER['PATH_INFO'] = '/accounts/42/regions/us'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/accounts/42/regions/us?active=1'; + $_SERVER['REDIRECT_URL'] = '/accounts/42/regions/us'; + + $this->app->controller( + '/accounts/([0-9]+)/regions/([a-z]+)', + VariadicController::class, + 'explicit' + ); + + $this->assertSame([ + 'explicit' => 'explicit', + 'account' => '42', + 'region' => 'us', + 'query' => ['active' => '1'], + ], $response_value); + } + public function testUnmatchedRouteDoesNotInvokeCallback(): void + { + global $response_value; + + $called = false; + + $_SERVER['PATH_INFO'] = '/actual'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/actual'; + $_SERVER['REDIRECT_URL'] = '/actual'; + + $this->app->route( + 'GET /different', + static function () use (&$called): string { + $called = true; + + return 'unexpected'; + } + ); + + $this->assertFalse($called); + $this->assertNull($response_value); } - /** - * @requires HHVM - */ - public function testPost() + public function testDeleteHelperDispatchesCapturesAndQuery(): void { global $response_value; - $controller = self::$app->create('App\ProductsController'); - $repository = self::$app->create('App\DB\ProductsRepository'); + $_GET = ['force' => '1']; + $_SERVER['PATH_INFO'] = '/items/42'; + $_SERVER['REQUEST_METHOD'] = 'DELETE'; + $_SERVER['REQUEST_URI'] = '/items/42?force=1'; + $_SERVER['REDIRECT_URL'] = '/items/42'; + + $this->app->DELETE( + '/items/([0-9]+)', + static fn (string $id, array $query): array => [ + 'id' => $id, + 'query' => $query, + ] + ); - $controller->auth = true; + $this->assertSame([ + 'id' => '42', + 'query' => ['force' => '1'], + ], $response_value); + } - $_POST = '{ "sku" : 10, "name" : "Test Product", "price" : 89.99 }'; + public function testControllerPatchDispatchesPathAndDecodedBody(): void + { + global $response_value; + + $_POST = '{"name":"patched"}'; + $_SERVER['PATH_INFO'] = '/42'; + $_SERVER['REQUEST_METHOD'] = 'PATCH'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['REQUEST_URI'] = '/42'; + $_SERVER['REDIRECT_URL'] = '/42'; - $_SERVER['PATH_INFO'] = "/"; - $_SERVER['REQUEST_METHOD'] = "POST"; - try { - self::$app->controller("/", $controller); - } catch (\Exception $ex) { - exit($ex->getMessage()); - } + $this->app->controller('/', new RoutingRegressionController()); + + $this->assertSame([ + 'handler' => 'patch', + 'id' => '42', + 'body' => ['name' => 'patched'], + ], $response_value); + } - if (is_object($response_value)) { // this test fails on travis - $this->assertEquals('App\Model\Product', get_class($response_value)); - } + public function testControllerOptionsPrefersMethodSpecificCustomAction(): void + { + global $response_value; - $this->assertEquals(3, $repository->count()); - $v = $repository->get(10); - $this->assertEquals(89.99, $v->price); + $_GET = ['probe' => '1']; + $_SERVER['PATH_INFO'] = '/status'; + $_SERVER['REQUEST_METHOD'] = 'OPTIONS'; + $_SERVER['REQUEST_URI'] = '/status?probe=1'; + $_SERVER['REDIRECT_URL'] = '/status'; + $this->app->controller('/', new RoutingRegressionController()); + $this->assertSame([ + 'handler' => 'optionsStatus', + 'query' => ['probe' => '1'], + ], $response_value); } - /** - * @requires HHVM - */ - public function testPut() + public function testControllerCustomHttpMethodFallsBackToCustomAction(): void { global $response_value; - $repository = self::$app->create('App\DB\ProductsRepository'); - $controller = self::$app->create('App\ProductsController'); - $controller->auth = true; + $_GET = ['probe' => '1']; + $_SERVER['PATH_INFO'] = '/status'; + $_SERVER['REQUEST_METHOD'] = 'VIEW'; + $_SERVER['REQUEST_URI'] = '/status?probe=1'; + $_SERVER['REDIRECT_URL'] = '/status'; - $_POST = '{ "name" : "Test Rename", "price" : 89.99 }'; + $this->app->controller('/', new RoutingRegressionController()); + + $this->assertSame([ + 'handler' => 'status', + 'query' => ['probe' => '1'], + ], $response_value); + } + + public function testIndex(): void + { + global $response_value; + + $this->route('GET', '/'); + + $this->assertCount(2, $response_value); + $this->assertSame(1, $response_value[0]->sku); + } + + public function testHeadUsesGetControllerSemantics(): void + { + global $response_value; + + $this->route('HEAD', '/2'); + + $this->assertSame(2, $response_value->sku); + } + + public function testHeadUsesGetCustomControllerMethod(): void + { + global $response_value; + + $this->route('HEAD', '/sale'); + + $this->assertSame(90, $response_value[0]->price); + } + + public function testGetHelperMatchesHeadRequests(): void + { + global $response_value; + + $_SERVER['PATH_INFO'] = '/health'; + $_SERVER['REQUEST_METHOD'] = 'HEAD'; + $_SERVER['REQUEST_URI'] = '/health'; + $_SERVER['REDIRECT_URL'] = '/health'; + + $this->app->GET('/health', static fn (array $query): string => 'ok'); + + $this->assertSame('ok', $response_value); + } + + public function testPatchHelperParsesRequestBody(): void + { + global $response_value; + + $_POST = '{"name":"patched"}'; + $_SERVER['PATH_INFO'] = '/product'; + $_SERVER['REQUEST_METHOD'] = 'PATCH'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['REQUEST_URI'] = '/product'; + $_SERVER['REDIRECT_URL'] = '/product'; - $_SERVER['PATH_INFO'] = "/10"; - $_SERVER['REQUEST_METHOD'] = "PUT"; + $this->app->PATCH('/product', static fn (array $body): array => $body); - self::$app->controller("/", $controller); - if (is_object($response_value)) { - $this->assertEquals("Test Rename", $response_value->name); - } + $this->assertSame(['name' => 'patched'], $response_value); + } + + public function testGet(): void + { + global $response_value; - $e = $repository->get(10); - $this->assertEquals("Test Rename", $e->name); + $this->route('GET', '/2'); + $this->assertSame(2, $response_value->sku); } - public function testCustomMethod() + public function testPost(): void { global $response_value; - // will call $controller->getSale - self::route("GET", "/sale"); + $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/json'; + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['REDIRECT_URL'] = '/'; + + $this->app->controller('/', $controller); + + $this->assertInstanceOf(Product::class, $response_value); + $this->assertSame(3, $repository->count()); + $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->assertEquals(90, $response_value[0]->price); + $this->app->controller('/', $controller); + + $this->assertInstanceOf(\RuntimeException::class, $response_value); + $this->assertSame(415, $response_code); } - public function testControllerParameters() + 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; - self::route("GET", "/hello"); - $this->assertEquals("Hello TEST", $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 testCustomMethodFallback() + public function testPut(): void { global $response_value; - // will call $controller->sale() as there is no $controller->viewSale() defined - self::route("VIEW", "/sale/8777"); + $repository = $this->app->create('App\\DB\\ProductsRepository'); + $controller = $this->app->create(ProductsController::class); - $this->assertEquals(8777, $response_value[0]->price); + $_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'; + + $this->app->controller('/', $controller); + + $this->assertSame('Test Rename', $response_value->name); + $this->assertSame('Test Rename', $repository->get(2)->name); } - public function testAppRun() + public function testCustomMethod(): void { global $response_value; - $_SERVER['PATH_INFO'] = "/say/hello"; - $_SERVER['REQUEST_METHOD'] = "GET"; + $this->route('GET', '/sale'); - self::$app->run('App'); + $this->assertSame(90, $response_value[0]->price); + } - $this->assertEquals('hello', $response_value); + public function testControllerParameters(): void + { + global $response_value; + + $this->route('GET', '/hello'); + + $this->assertSame('Hello TEST', $response_value); + } + + public function testCustomMethodFallback(): void + { + global $response_value; + + $this->route('VIEW', '/sale/8777'); + + $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; + + $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; -// $_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 +} diff --git a/test/ErrorBoundaryTest.php b/test/ErrorBoundaryTest.php new file mode 100644 index 0000000..9800b2a --- /dev/null +++ b/test/ErrorBoundaryTest.php @@ -0,0 +1,274 @@ +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_debug; + $response_value = null; + $response_code = null; + $response_debug = 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 testVerbHelpersKeepInvalidCallbacksInsideBoundary(): void + { + global $response_value, $response_code; + + foreach (['GET', 'POST', 'PUT', 'DELETE'] as $method) { + $response_value = null; + $response_code = null; + + $_POST = []; + $_SERVER['PATH_INFO'] = '/invalid-helper'; + $_SERVER['REQUEST_METHOD'] = $method; + $_SERVER['REQUEST_URI'] = '/invalid-helper'; + $_SERVER['REDIRECT_URL'] = '/invalid-helper'; + unset($_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_CONTENT_TYPE']); + + $this->router->{$method}('/invalid-helper', 'not-a-callable'); + + $this->assertInstanceOf(\RuntimeException::class, $response_value); + $this->assertSame(500, $response_code); + } + } + + public function testGetHelperResolvesClassCallbackThroughDice(): void + { + global $response_value, $response_code; + + $_GET = ['filter' => 'active']; + $_SERVER['PATH_INFO'] = '/class-helper'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/class-helper'; + $_SERVER['REDIRECT_URL'] = '/class-helper'; + + $this->router->GET( + '/class-helper', + [ErrorBoundaryVerbController::class, 'get'] + ); + + $this->assertSame(['filter' => 'active'], $response_value); + $this->assertSame(200, $response_code); + } + + public function testPostHelperResolvesClassCallbackAndParsesBodyInsideBoundary(): void + { + global $response_value, $response_code; + + $_POST = '{"name":"router"}'; + $_SERVER['PATH_INFO'] = '/class-helper'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['REQUEST_URI'] = '/class-helper'; + $_SERVER['REDIRECT_URL'] = '/class-helper'; + + $this->router->POST( + '/class-helper', + [ErrorBoundaryVerbController::class, 'post'] + ); + + $this->assertSame(['name' => 'router'], $response_value); + $this->assertSame(200, $response_code); + } + + public function testPostHelperBodyParsingFailureStaysInsideBoundary(): void + { + global $response_value, $response_code; + + $_POST = '{"name":'; + $_SERVER['PATH_INFO'] = '/body-error'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['REQUEST_URI'] = '/body-error'; + $_SERVER['REDIRECT_URL'] = '/body-error'; + + $this->router->POST('/body-error', static fn (array $body) => $body); + + $this->assertInstanceOf(\RuntimeException::class, $response_value); + $this->assertSame('Invalid JSON request body', $response_value->getMessage()); + $this->assertSame(400, $response_code); + } + + public function testServerErrorLogsOriginalThrowableDetails(): void + { + $logFile = tempnam(sys_get_temp_dir(), 'objectiveweb-router-error-'); + $previousLog = ini_get('error_log'); + + try { + ini_set('error_log', $logFile); + + $_SERVER['PATH_INFO'] = '/logged-error'; + $_SERVER['REQUEST_URI'] = '/logged-error'; + $_SERVER['REDIRECT_URL'] = '/logged-error'; + + $this->router->GET('/logged-error', static function (): void { + throw new \RuntimeException('sensitive diagnostic detail'); + }); + + $log = file_get_contents($logFile); + + $this->assertStringContainsString( + \RuntimeException::class, + $log + ); + $this->assertStringContainsString( + 'sensitive diagnostic detail', + $log + ); + } finally { + ini_set('error_log', $previousLog); + @unlink($logFile); + } + } + + public function testRouterDebugConfigurationPropagatesToResponsePipeline(): void + { + global $response_debug; + + $router = new Router(null, ['debug' => true]); + + $_SERVER['PATH_INFO'] = '/debug'; + $_SERVER['REQUEST_URI'] = '/debug'; + $_SERVER['REDIRECT_URL'] = '/debug'; + + $router->GET('/debug', static function (): void { + throw new \RuntimeException('debug detail'); + }); + + $this->assertTrue($response_debug); + } + + public function testDebugConfigurationMustBeBoolean(): void + { + $this->expectException(\InvalidArgumentException::class); + + new Router(null, ['debug' => 'yes']); + } + + public function testThrowableUsesStandardErrorEnvelope(): void + { + $response = Router::prepareResponseForTest( + new \TypeError('Bad argument'), + 'application/json', + 500, + true + ); + + $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']); + } +} diff --git a/test/HttpIntegrationTest.php b/test/HttpIntegrationTest.php new file mode 100644 index 0000000..f25896b --- /dev/null +++ b/test/HttpIntegrationTest.php @@ -0,0 +1,542 @@ + ['pipe', 'r'], + 1 => ['file', self::$stdoutLog, 'a'], + 2 => ['file', self::$stderrLog, 'a'], + ], + $pipes, + dirname(__DIR__) + ); + + if (!is_resource(self::$server)) { + throw new RuntimeException('Unable to start PHP built-in server'); + } + + fclose($pipes[0]); + + $deadline = microtime(true) + 5; + do { + $socket = @stream_socket_client( + 'tcp://127.0.0.1:' . self::$port, + $errno, + $errstr, + 0.1 + ); + + if (is_resource($socket)) { + fclose($socket); + return; + } + + usleep(25_000); + } while (microtime(true) < $deadline); + + $stderr = @file_get_contents(self::$stderrLog) ?: ''; + self::stopServer(); + + throw new RuntimeException( + "PHP built-in server did not start. stderr: $stderr" + ); + } + + public static function tearDownAfterClass(): void + { + self::stopServer(); + + @unlink(self::$stdoutLog); + @unlink(self::$stderrLog); + } + + public function testImmediateRoutingSkipsNoMatchAndStopsAtFirstMatch(): void + { + $response = $this->request('GET', '/routing-order', [ + 'Accept' => 'text/html', + ]); + + $this->assertSame(200, $response['status']); + $this->assertSame('first', $response['body']); + } + + public function testHtmlNegotiationEmitsStatusContentTypeVaryAndBody(): void + { + $response = $this->request('GET', '/negotiate', [ + 'Accept' => 'text/html', + ]); + + $this->assertSame(200, $response['status']); + $this->assertHeaderContains($response, 'content-type', 'text/html; charset=utf-8'); + $this->assertHeaderContains($response, 'vary', 'Accept'); + $this->assertSame('

    Hello

    ', $response['body']); + } + + public function testJsonNegotiationEmitsJsonRepresentation(): void + { + $response = $this->request('GET', '/negotiate', [ + 'Accept' => 'application/json', + ]); + + $this->assertSame(200, $response['status']); + $this->assertHeaderContains($response, 'content-type', 'application/json; charset=utf-8'); + $this->assertHeaderContains($response, 'vary', 'Accept'); + $this->assertSame('"

    Hello<\/p>"', $response['body']); + } + + public function testRegisteredSerializerEmitsCustomJsonAtHttpBoundary(): void + { + $response = $this->request('GET', '/custom-serializer', [ + 'Accept' => 'application/json', + ]); + + $this->assertSame(200, $response['status']); + $this->assertHeaderContains( + $response, + 'content-type', + 'application/json; charset=utf-8' + ); + $this->assertSame( + '{"resource":"router","version":3}', + $response['body'] + ); + } + + public function testUnsupportedAcceptEmits406AndEmptyBody(): void + { + $response = $this->request('GET', '/negotiate', [ + 'Accept' => 'image/png', + ]); + + $this->assertSame(406, $response['status']); + $this->assertHeaderContains($response, 'vary', 'Accept'); + $this->assertSame('', $response['body']); + } + + public function testHeadUsesGetHeadersButEmitsNoBody(): void + { + $response = $this->request('HEAD', '/head', [ + 'Accept' => 'text/html', + ]); + + $this->assertSame(200, $response['status']); + $this->assertHeaderContains($response, 'content-type', 'text/html; charset=utf-8'); + $this->assertSame('', $response['body']); + } + + public function test204And304EmitNoBody(): void + { + foreach ([204, 304] as $status) { + $response = $this->request('GET', "/status/$status", [ + 'Accept' => 'application/json', + ]); + + $this->assertSame($status, $response['status']); + $this->assertSame('', $response['body']); + } + } + + public function testRelativeRedirectEmitsLocationAndStatus(): void + { + $response = $this->request('GET', '/redirect-relative'); + + $this->assertSame(302, $response['status']); + $this->assertHeaderContains($response, 'location', '/target'); + $this->assertSame('', $response['body']); + } + + public function testAbsoluteRedirectPassesLocationThrough(): void + { + $response = $this->request('GET', '/redirect-absolute'); + + $this->assertSame(307, $response['status']); + $this->assertHeaderContains( + $response, + 'location', + 'https://example.com/target' + ); + $this->assertSame('', $response['body']); + } + + public function testCorsSimpleRequestEmitsCorsHeaders(): void + { + $response = $this->request('GET', '/cors', [ + 'Origin' => 'https://client.example', + 'Accept' => 'application/json', + ]); + + $this->assertSame(200, $response['status']); + $this->assertHeaderContains( + $response, + 'access-control-allow-origin', + 'https://client.example' + ); + $this->assertHeaderContains( + $response, + 'access-control-allow-credentials', + 'true' + ); + $this->assertHeaderContains( + $response, + 'access-control-expose-headers', + 'content-range' + ); + $this->assertSame('{"cors":true}', $response['body']); + } + + public function testWildcardCorsAutomaticallyDisablesCredentials(): void + { + $response = $this->request('GET', '/cors-wildcard', [ + 'Origin' => 'https://any.example', + 'Accept' => 'application/json', + ]); + + $this->assertSame(200, $response['status']); + $this->assertHeaderContains( + $response, + 'access-control-allow-origin', + '*' + ); + $this->assertHeaderMissing( + $response, + 'access-control-allow-credentials' + ); + } + + public function testCorsCredentialsCanBeDisabledExplicitly(): void + { + $response = $this->request('GET', '/cors-no-credentials', [ + 'Origin' => 'https://client.example', + 'Accept' => 'application/json', + ]); + + $this->assertSame(200, $response['status']); + $this->assertHeaderContains( + $response, + 'access-control-allow-origin', + 'https://client.example' + ); + $this->assertHeaderMissing( + $response, + 'access-control-allow-credentials' + ); + } + + public function testCorsPreflightUsesExplicitAllowedHeaders(): void + { + $response = $this->request('OPTIONS', '/cors-explicit-headers', [ + 'Origin' => 'https://client.example', + 'Access-Control-Request-Method' => 'POST', + 'Access-Control-Request-Headers' => 'X-Ignored', + ]); + + $this->assertSame(204, $response['status']); + $this->assertHeaderContains( + $response, + 'access-control-allow-methods', + 'GET, POST, OPTIONS' + ); + $this->assertHeaderContains( + $response, + 'access-control-allow-headers', + 'Authorization, X-Request-ID' + ); + $this->assertHeaderMissingValue( + $response, + 'access-control-allow-headers', + 'X-Ignored' + ); + } + + public function testSetCorsReplacesPreviouslyConfiguredCorsMiddleware(): void + { + $response = $this->request('GET', '/cors-set-replace', [ + 'Origin' => 'https://new.example', + 'Accept' => 'application/json', + ]); + + $this->assertSame(200, $response['status']); + $this->assertHeaderContains( + $response, + 'access-control-allow-origin', + 'https://new.example' + ); + $this->assertHeaderMissingValue( + $response, + 'access-control-allow-origin', + 'https://old.example' + ); + } + + public function testCorsPreflightTerminatesWith204AndActualHeaders(): void + { + $response = $this->request('OPTIONS', '/cors', [ + 'Origin' => 'https://client.example', + 'Access-Control-Request-Method' => 'PATCH', + 'Access-Control-Request-Headers' => 'Authorization, Content-Type', + ]); + + $this->assertSame(204, $response['status']); + $this->assertHeaderContains( + $response, + 'access-control-allow-origin', + 'https://client.example' + ); + $this->assertHeaderContains( + $response, + 'access-control-allow-methods', + 'GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS' + ); + $this->assertHeaderContains( + $response, + 'access-control-allow-headers', + 'Authorization, Content-Type' + ); + $this->assertSame('', $response['body']); + } + + public function test404PreservesDetailedJsonError(): void + { + $response = $this->request('GET', '/error-404', [ + 'Accept' => 'application/json', + ]); + + $this->assertSame(404, $response['status']); + $this->assertHeaderContains( + $response, + 'content-type', + 'application/json; charset=utf-8' + ); + + $body = json_decode($response['body'], true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame(RuntimeException::class, $body['exception']); + $this->assertSame('Missing resource', $body['message']); + } + + public function test500IsRedactedAtActualHttpBoundary(): void + { + $response = $this->request('GET', '/error-500', [ + 'Accept' => 'application/json', + ]); + + $this->assertSame(500, $response['status']); + + $body = json_decode($response['body'], true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame(['message' => 'Internal Server Error'], $body); + $this->assertStringNotContainsString( + 'sensitive server detail', + $response['body'] + ); + } + + public function testDebug500ExposesDetailsAtActualHttpBoundary(): void + { + $response = $this->request('GET', '/error-500-debug', [ + 'Accept' => 'application/json', + ]); + + $this->assertSame(500, $response['status']); + + $body = json_decode($response['body'], true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame(RuntimeException::class, $body['exception']); + $this->assertSame('debug server detail', $body['message']); + } + + private static function reservePort(): int + { + $socket = stream_socket_server( + 'tcp://127.0.0.1:0', + $errno, + $errstr + ); + + if (!is_resource($socket)) { + throw new RuntimeException( + "Unable to reserve local HTTP port: $errstr ($errno)" + ); + } + + $name = stream_socket_get_name($socket, false); + fclose($socket); + + if ($name === false || !preg_match('/:(\d+)$/', $name, $matches)) { + throw new RuntimeException('Unable to determine reserved HTTP port'); + } + + return (int) $matches[1]; + } + + private static function stopServer(): void + { + if (!is_resource(self::$server)) { + return; + } + + proc_terminate(self::$server); + proc_close(self::$server); + self::$server = null; + } + + /** + * @param array $headers + * @return array{status:int,headers:array>,body:string} + */ + private function request( + string $method, + string $path, + array $headers = [], + ?string $body = null + ): array { + $socket = stream_socket_client( + 'tcp://127.0.0.1:' . self::$port, + $errno, + $errstr, + 5 + ); + + if (!is_resource($socket)) { + $stderr = @file_get_contents(self::$stderrLog) ?: ''; + $this->fail( + "Unable to connect to test server: $errstr ($errno)\n$stderr" + ); + } + + $requestHeaders = [ + 'Host' => '127.0.0.1:' . self::$port, + 'Connection' => 'close', + ...$headers, + ]; + + if ($body !== null) { + $requestHeaders['Content-Length'] = (string) strlen($body); + } + + $request = "$method $path HTTP/1.1\r\n"; + foreach ($requestHeaders as $name => $value) { + $request .= "$name: $value\r\n"; + } + $request .= "\r\n"; + $request .= $body ?? ''; + + fwrite($socket, $request); + $raw = stream_get_contents($socket); + fclose($socket); + + if ($raw === false || !str_contains($raw, "\r\n\r\n")) { + $stderr = @file_get_contents(self::$stderrLog) ?: ''; + $this->fail("Malformed HTTP response:\n$raw\nServer stderr:\n$stderr"); + } + + [$rawHeaders, $responseBody] = explode("\r\n\r\n", $raw, 2); + $lines = explode("\r\n", $rawHeaders); + $statusLine = array_shift($lines); + + if (!preg_match('#^HTTP/\d(?:\.\d)?\s+(\d{3})#', $statusLine, $matches)) { + $this->fail("Malformed HTTP status line: $statusLine"); + } + + $parsedHeaders = []; + foreach ($lines as $line) { + if (!str_contains($line, ':')) { + continue; + } + + [$name, $value] = explode(':', $line, 2); + $parsedHeaders[strtolower(trim($name))][] = trim($value); + } + + return [ + 'status' => (int) $matches[1], + 'headers' => $parsedHeaders, + 'body' => $responseBody, + ]; + } + + /** + * @param array{headers:array>} $response + */ + private function assertHeaderMissing( + array $response, + string $name + ): void { + $this->assertArrayNotHasKey( + strtolower($name), + $response['headers'], + "Did not expect header $name" + ); + } + + /** + * @param array{headers:array>} $response + */ + private function assertHeaderMissingValue( + array $response, + string $name, + string $unexpected + ): void { + $values = $response['headers'][strtolower($name)] ?? []; + + $this->assertNotContains( + $unexpected, + $values, + sprintf( + 'Did not expect header %s: %s; got %s', + $name, + $unexpected, + implode(' | ', $values) + ) + ); + } + + /** + * @param array{headers:array>} $response + */ + private function assertHeaderContains( + array $response, + string $name, + string $expected + ): void { + $values = $response['headers'][strtolower($name)] ?? []; + + $this->assertContains( + $expected, + $values, + sprintf( + 'Expected header %s: %s; got %s', + $name, + $expected, + implode(' | ', $values) + ) + ); + } +} diff --git a/test/MiddlewareTest.php b/test/MiddlewareTest.php new file mode 100644 index 0000000..91c8a44 --- /dev/null +++ b/test/MiddlewareTest.php @@ -0,0 +1,283 @@ +name; + + return $params; + } + + public function after(string $method, string $fn, array $params, mixed $response): mixed + { + self::$events[] = 'after:' . $this->name; + + return $response; + } +} + +class SharedMiddlewareDependency +{ +} + +class DependencyAwareMiddleware +{ + public static array $middlewareIds = []; + public static array $dependencyIds = []; + + public function __construct( + public SharedMiddlewareDependency $dependency, + private string $name + ) { + } + + public function before(string $method, string $fn, array $params): array + { + self::$middlewareIds[] = spl_object_id($this); + self::$dependencyIds[] = spl_object_id($this->dependency); + + return $params; + } +} + +#[Middleware(DependencyAwareMiddleware::class, 'first')] +#[Middleware(DependencyAwareMiddleware::class, 'second')] +class DependencyAwareMiddlewareController +{ + public static ?int $dependencyId = null; + + public function __construct(SharedMiddlewareDependency $dependency) + { + self::$dependencyId = spl_object_id($dependency); + } + + public function index(array $query): string + { + return 'ok'; + } +} + +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 = []; + DependencyAwareMiddleware::$middlewareIds = []; + DependencyAwareMiddleware::$dependencyIds = []; + DependencyAwareMiddlewareController::$dependencyId = null; + 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 testMiddlewareAttributeHasTypedPublicContract(): void + { + $attribute = new Middleware( + RecordingMiddleware::class, + 'first', + 2, + ['third' => true] + ); + + $this->assertSame(RecordingMiddleware::class, $attribute->getClass()); + $this->assertSame( + ['first', 2, ['third' => true]], + $attribute->getArgs() + ); + + $constructor = new \ReflectionMethod(Middleware::class, '__construct'); + $parameters = $constructor->getParameters(); + + $this->assertSame('string', (string) $parameters[0]->getType()); + $this->assertTrue($parameters[1]->isVariadic()); + $this->assertSame('mixed', (string) $parameters[1]->getType()); + + $this->assertSame( + 'string', + (string) (new \ReflectionMethod(Middleware::class, 'getClass'))->getReturnType() + ); + $this->assertSame( + 'array', + (string) (new \ReflectionMethod(Middleware::class, 'getArgs'))->getReturnType() + ); + + $property = new \ReflectionProperty(Middleware::class, 'args'); + $this->assertSame('array', (string) $property->getType()); + } + + 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 testRepeatedMiddlewareInstancesAreDistinctAndShareNormalDependencies(): void + { + global $response_value; + + $this->router->addRule(SharedMiddlewareDependency::class, [ + 'shared' => true, + ]); + + $this->router->controller('/', DependencyAwareMiddlewareController::class); + + $this->assertSame('ok', $response_value); + $this->assertCount(2, DependencyAwareMiddleware::$middlewareIds); + $this->assertNotSame( + DependencyAwareMiddleware::$middlewareIds[0], + DependencyAwareMiddleware::$middlewareIds[1] + ); + + $this->assertCount(2, DependencyAwareMiddleware::$dependencyIds); + $this->assertSame( + DependencyAwareMiddleware::$dependencyIds[0], + DependencyAwareMiddleware::$dependencyIds[1] + ); + $this->assertSame( + DependencyAwareMiddlewareController::$dependencyId, + DependencyAwareMiddleware::$dependencyIds[0] + ); + } + + 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); + } +} diff --git a/test/PublicApiTest.php b/test/PublicApiTest.php new file mode 100644 index 0000000..e6a00f7 --- /dev/null +++ b/test/PublicApiTest.php @@ -0,0 +1,89 @@ +assertFalse(method_exists(Router::class, '_call')); + } + + public function testIsAjaxHasBooleanContract(): void + { + $method = new \ReflectionMethod(Router::class, 'isAjax'); + + $this->assertSame('bool', (string) $method->getReturnType()); + + unset($_SERVER['HTTP_X_REQUESTED_WITH']); + $this->assertFalse(Router::isAjax()); + + $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; + $this->assertTrue(Router::isAjax()); + + unset($_SERVER['HTTP_X_REQUESTED_WITH']); + } + + public function testStraightforwardPublicMethodsExposeTypedContracts(): void + { + $expectations = [ + 'addSerializer' => ['void', 2], + 'hasSerializer' => ['bool', 1], + 'addRule' => ['void', 2], + 'create' => ['object', 2], + 'template' => ['?Objectiveweb\\Router\\Template', 3], + 'route' => ['void', 3], + 'controller' => ['void', 3], + 'DELETE' => ['void', 2], + 'GET' => ['void', 2], + 'POST' => ['void', 2], + 'PUT' => ['void', 2], + 'PATCH' => ['void', 2], + 'run' => ['void', 1], + 'url' => ['string', 1], + 'parse_post_body' => ['mixed', 2], + 'redirect' => ['never', 2], + 'negotiateContentType' => ['?string', 2], + 'isAjax' => ['bool', 0], + ]; + + foreach ($expectations as $methodName => [$returnType, $parameters]) { + $method = new \ReflectionMethod(Router::class, $methodName); + + $this->assertSame( + $returnType, + (string) $method->getReturnType(), + "$methodName return type" + ); + $this->assertSame( + $parameters, + $method->getNumberOfParameters(), + "$methodName parameter count" + ); + } + } + + public function testRouteAndControllerExposeVariadicArguments(): void + { + foreach (['route', 'controller'] as $methodName) { + $method = new \ReflectionMethod(Router::class, $methodName); + $params = $method->getParameters(); + + $this->assertCount(3, $params); + $this->assertTrue($params[2]->isVariadic(), "$methodName args must be variadic"); + $this->assertSame('mixed', (string) $params[2]->getType()); + $this->assertSame(2, $method->getNumberOfRequiredParameters()); + } + } + + public function testRouteCallbackRemainsMixedForControlledErrorBoundary(): void + { + $method = new \ReflectionMethod(Router::class, 'route'); + $callback = $method->getParameters()[1]; + + $this->assertSame('mixed', (string) $callback->getType()); + } +} diff --git a/test/RequestBodyTest.php b/test/RequestBodyTest.php new file mode 100644 index 0000000..68cb05c --- /dev/null +++ b/test/RequestBodyTest.php @@ -0,0 +1,126 @@ +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 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'; + $_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) + ); + } +} diff --git a/test/RequestMiddlewareTest.php b/test/RequestMiddlewareTest.php new file mode 100644 index 0000000..2e24bf4 --- /dev/null +++ b/test/RequestMiddlewareTest.php @@ -0,0 +1,211 @@ +dependency); + RequestMiddlewareEvents::$events[] = "request-before:{$this->name}:$method:$path"; + } + + public function after(string $method, string $path, mixed $response): mixed + { + RequestMiddlewareEvents::$events[] = "request-after:{$this->name}:$method:$path"; + + return is_string($response) + ? $response . '|' . $this->name + : $response; + } +} + +class RecordingControllerMiddleware +{ + public function before(string $method, string $fn, array $params): array + { + RequestMiddlewareEvents::$events[] = 'controller-before'; + + return $params; + } + + public function after(string $method, string $fn, array $params, mixed $response): mixed + { + RequestMiddlewareEvents::$events[] = 'controller-after'; + + return $response; + } +} + +#[Middleware(RecordingControllerMiddleware::class)] +class RequestMiddlewareController +{ + public function index(array $query): string + { + RequestMiddlewareEvents::$events[] = 'controller'; + + return 'ok'; + } +} + +class RequestMiddlewareTest extends TestCase +{ + protected function setUp(): void + { + RequestMiddlewareEvents::$events = []; + RecordingRequestMiddleware::$dependencyIds = []; + + $_GET = []; + $_POST = []; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['PATH_INFO'] = '/'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['REDIRECT_URL'] = '/'; + unset( + $_SERVER['HTTP_ACCEPT'], + $_SERVER['HTTP_ORIGIN'], + $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'], + $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] + ); + + global $response_value, $response_code; + $response_value = null; + $response_code = null; + } + + public function testRequestMiddlewareWrapsControllerMiddleware(): void + { + global $response_value; + + $router = new Router(null, [ + 'request.middlewares' => [ + RecordingRequestMiddleware::class => ['outer'], + ], + ]); + $router->addRule(RequestDependency::class, ['shared' => true]); + $router->addRequestMiddleware(RecordingRequestMiddleware::class, ['inner']); + + $router->controller('/', RequestMiddlewareController::class); + + $this->assertSame('ok|inner|outer', $response_value); + $this->assertSame([ + 'request-before:outer:GET:/', + 'request-before:inner:GET:/', + 'controller-before', + 'controller', + 'controller-after', + 'request-after:inner:GET:/', + 'request-after:outer:GET:/', + ], RequestMiddlewareEvents::$events); + + $this->assertCount(2, RecordingRequestMiddleware::$dependencyIds); + $this->assertSame( + RecordingRequestMiddleware::$dependencyIds[0], + RecordingRequestMiddleware::$dependencyIds[1] + ); + } + + public function testRequestBeforeRunsBeforeMethodSpecificRouteMatching(): void + { + global $response_value; + + $_SERVER['PATH_INFO'] = '/direct'; + $_SERVER['REQUEST_METHOD'] = 'OPTIONS'; + $_SERVER['REQUEST_URI'] = '/direct'; + $_SERVER['REDIRECT_URL'] = '/direct'; + + $router = new Router(); + $router->addRequestMiddleware(RecordingRequestMiddleware::class, ['global']); + + $router->GET('/direct', static fn (): string => 'unreachable'); + + $this->assertNull($response_value); + $this->assertSame([ + 'request-before:global:OPTIONS:/direct', + ], RequestMiddlewareEvents::$events); + } + + public function testRequestMiddlewareWrapsDirectRoutes(): void + { + global $response_value; + + $_SERVER['PATH_INFO'] = '/direct'; + $_SERVER['REQUEST_URI'] = '/direct'; + $_SERVER['REDIRECT_URL'] = '/direct'; + + $router = new Router(); + $router->addRequestMiddleware(RecordingRequestMiddleware::class, ['direct']); + + $router->GET('/direct', function (): string { + RequestMiddlewareEvents::$events[] = 'callback'; + + return 'ok'; + }); + + $this->assertSame('ok|direct', $response_value); + $this->assertSame([ + 'request-before:direct:GET:/direct', + 'callback', + 'request-after:direct:GET:/direct', + ], RequestMiddlewareEvents::$events); + } + + public function testRequestAfterDoesNotRunWhenCallbackThrows(): void + { + global $response_value, $response_code; + + $_SERVER['PATH_INFO'] = '/failure'; + $_SERVER['REQUEST_URI'] = '/failure'; + $_SERVER['REDIRECT_URL'] = '/failure'; + + $router = new Router(); + $router->addRequestMiddleware(RecordingRequestMiddleware::class, ['request']); + + $router->GET('/failure', function (): void { + RequestMiddlewareEvents::$events[] = 'callback'; + throw new \RuntimeException('failure', 500); + }); + + $this->assertInstanceOf(\RuntimeException::class, $response_value); + $this->assertSame(500, $response_code); + $this->assertSame([ + 'request-before:request:GET:/failure', + 'callback', + ], RequestMiddlewareEvents::$events); + } + + public function testCorsAfterReturnsResponseUnchanged(): void + { + $cors = new CorsMiddleware('https://app.example'); + + $this->assertSame( + ['ok' => true], + $cors->after('GET', '/products', ['ok' => true]) + ); + } +} diff --git a/test/ResponseNegotiationTest.php b/test/ResponseNegotiationTest.php new file mode 100644 index 0000000..c8eec06 --- /dev/null +++ b/test/ResponseNegotiationTest.php @@ -0,0 +1,311 @@ +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 testHeadSuppressesBodyButPreservesRepresentation(): void + { + $response = Router::prepareHttpResponseForTest( + ['ok' => true], + 200, + 'application/json', + 'HEAD' + ); + + $this->assertSame(200, $response['status']); + $this->assertSame('application/json', $response['content_type']); + $this->assertSame('', $response['body']); + } + + public function testNoContentStatusesNeverContainBodyOrNegotiateRepresentation(): void + { + foreach ([101, 199, 204, 205, 304] as $status) { + $response = Router::prepareHttpResponseForTest( + ['ignored' => true], + $status, + 'image/png' + ); + + $this->assertSame($status, $response['status']); + $this->assertSame('', $response['body']); + $this->assertNull($response['content_type']); + $this->assertFalse($response['vary_accept']); + } + } + + 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 testThrowableRedacts500ForHtmlClientByDefault(): void + { + $response = Router::prepareHttpResponseForTest( + new \TypeError('Database password: secret'), + 500, + 'text/html' + ); + + $this->assertSame(500, $response['status']); + $this->assertSame('text/html', $response['content_type']); + $this->assertStringContainsString('Internal Server Error', $response['body']); + $this->assertStringNotContainsString('TypeError', $response['body']); + $this->assertStringNotContainsString('secret', $response['body']); + } + + public function testThrowableRedacts500ForJsonClientByDefault(): void + { + $response = Router::prepareHttpResponseForTest( + new \RuntimeException('Database password: secret', 500), + 500, + 'application/json' + ); + + $body = json_decode($response['body'], true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame(500, $response['status']); + $this->assertSame('application/json', $response['content_type']); + $this->assertSame(['message' => 'Internal Server Error'], $body); + } + + public function testDebugModeExposes500Details(): void + { + $response = Router::prepareHttpResponseForTest( + new \RuntimeException('Detailed failure', 500), + 500, + 'application/json', + null, + true + ); + + $body = json_decode($response['body'], true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame(\RuntimeException::class, $body['exception']); + $this->assertSame('Detailed failure', $body['message']); + } + + 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 testThrowableHtmlEscapesMessageInDebugMode(): void + { + $response = Router::prepareHttpResponseForTest( + new \RuntimeException('', 500), + 500, + 'text/html', + null, + true + ); + + $this->assertStringNotContainsString('