diff --git a/docs/Router.md b/docs/Router.md new file mode 100644 index 0000000..cd16e2e --- /dev/null +++ b/docs/Router.md @@ -0,0 +1,101 @@ +# Router + +`Novaconium\Router` resolves the current HTTP request to a controller file, based on a route table defined in PHP config files. It supports exact-match routes and simple parameterized routes (e.g. `/users/{id}`). + +## How it works + +On construction, the router runs through this pipeline: + +1. **Load routes** — merges framework-level routes with app-level routes. +2. **Prepare path** — normalizes `REQUEST_URI` into a clean path string. +3. **Prepare query** — parses the query string into an array. +4. **Determine request type** — `get` or `post`. +5. **Find controller** — matches the path against the route table. +6. **Resolve controller file** — turns the matched controller string into an actual file path, falling back to a 404 controller if needed. + +## Properties + +| Property | Type | Description | +|---|---|---| +| `$routes` | array | Combined route table (framework + app routes), keyed by path pattern. | +| `$query` | array | Parsed query string parameters. | +| `$path` | string | Normalized request path. | +| `$controller` | string | Resolved controller identifier (e.g. `Users/show` or `NOVACONIUM/Errors`). | +| `$controllerPath` | string | Absolute file path to the controller. | +| `$parameters` | array | Named parameters extracted from a parameterized route match. | +| `$requestType` | string | `'get'` or `'post'`. | + +## Route table format + +Routes are defined as an associative array, keyed by URL path. Each entry maps HTTP methods to a controller string: + +```php +$routes = [ + '/' => [ + 'get' => 'Home/index', + ], + '/users/{id}' => [ + 'get' => 'Users/show', + ], + '/login' => [ + 'get' => 'Auth/loginForm', + 'post' => 'Auth/login', + ], +]; +``` + +Controller strings starting with `NOVACONIUM` are resolved against the framework's controller directory (e.g. `NOVACONIUM/Errors` → `FRAMEWORKPATH/controllers/Errors.php`); all other controller strings are resolved against the app's controller directory (`BASEPATH/App/controllers/`). + +## Matching behavior + +### 1. Exact match + +The router first checks for an exact match between `$this->path` and a key in `$this->routes`. If found, and the current request type has a handler defined, that controller is returned immediately. + +### 2. Parameterized match + +If no exact match is found, the router scans the route table for patterns containing `{`. For each candidate: + +- The **static prefix** of the pattern (everything before the first `{`) must prefix-match the current path. +- The pattern and the path must have the **same number of `/`-separated segments**. + +The first route pattern satisfying both conditions is treated as the match — the router does not keep searching for a "better" match after this point, even if the matched route turns out to have no handler for the current request method (see [Known quirks](#known-quirks) below). + +Once a candidate route is selected, the router walks its segments looking for the first `{param}` segment, extracts the corresponding path segment into `$this->parameters[paramName]`, and returns immediately with the controller for the current request type. + +### 3. No match + +If neither an exact nor a parameterized match is found, `$this->controller` is set to `'404'`, and `setRouteFile()` will fail to find a corresponding controller file, triggering the 404 fallback described below. + +## Controller file resolution (`setRouteFile`) + +Given the resolved `$this->controller` string: + +1. If it starts with `NOVACONIUM`, look in `FRAMEWORKPATH/controllers/`. +2. Otherwise, look in `BASEPATH/App/controllers/`. +3. If the file exists, use it. +4. If not, fall back to `BASEPATH/App/controllers/404.php` if it exists. +5. Otherwise, fall back to the framework's built-in `FRAMEWORKPATH/controllers/404.php`. + +## Known quirks + +These behaviors exist in the current implementation and are preserved intentionally for backward compatibility — they're documented here so they aren't mistaken for bugs during future changes. + +- **Only the first `{param}` in a route pattern is captured.** If a route pattern has multiple parameters (e.g. `/users/{id}/posts/{postId}`), only `id` is extracted into `$this->parameters`; `postId` is never processed, because the matching loop returns as soon as it finds the first parameter segment. +- **The first prefix+segment-count match wins, even without a method handler.** If a parameterized route's static prefix and segment count match the request, but that route has no handler defined for the current request type (e.g. the route only defines `get` but the request is `post`), the router still commits to that route and returns `null` as the controller (which then falls through to the 404 controller). It does **not** continue searching for another route that might have a valid handler. +- **No support for trailing wildcard or optional segments.** Segment counts must match exactly between the pattern and the path; there's no support for `*` or optional `{param?}` style segments. + +## Debugging + +Call `$router->debug()` to dump the resolved path, controller path, extracted parameters, and full route table as an HTML table, then halt execution via `die()`. Intended for development use only. + +## Example + +```php +$router = new \Novaconium\Router(); + +include $router->controllerPath; + +// Inside the controller, access route parameters via: +$router->parameters['id']; +``` diff --git a/src/Router.php b/src/Router.php index 1cf5f06..a5f240a 100644 --- a/src/Router.php +++ b/src/Router.php @@ -1,5 +1,6 @@ routes = $this->loadRoutes(); - $this->path = $this->preparePath(); - $this->query = $this->prepareQuery(); - $this->requestType = $this->getRequestType(); - $this->controller = $this->findController(); - $this->controllerPath = $this->setRouteFile(); + $this->routes = $this->loadRoutes(); + $this->path = $this->preparePath(); + $this->query = $this->prepareQuery(); + $this->requestType = $this->getRequestType(); + $this->controller = $this->findController(); + $this->controllerPath = $this->setRouteFile(); } + /** + * Merge framework routes with app-defined routes (app routes can override framework routes). + */ private function loadRoutes() { - require_once( \FRAMEWORKPATH . '/config/routes.php'); - // Check if Path exists - if (file_exists(\BASEPATH . '/App/routes.php')) { - require_once( \BASEPATH . '/App/routes.php'); - } - $routes = array_merge((array)$routes, (array)$framework_routes); + require_once(\FRAMEWORKPATH . '/config/routes.php'); - return $routes; + if (file_exists(\BASEPATH . '/App/routes.php')) { + require_once(\BASEPATH . '/App/routes.php'); + } + + return array_merge((array)$routes, (array)$framework_routes); } + /** + * Normalize the request path: strip query string, trailing slash, and anything after "&". + */ private function preparePath() { $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); - //homepage if ($path === '/') { return $path; } - // remove empty directory path - $path = rtrim($path, '/'); // remove trailing slash - - //remove anything after and including ampersand + $path = rtrim($path, '/'); $path = preg_replace('/&.+$/', '', $path); - + return $path; } private function prepareQuery() { - $parsedUri = parse_url($_SERVER['REQUEST_URI']); + $parsedUri = parse_url($_SERVER['REQUEST_URI']); $queryArray = []; + if (isset($parsedUri['query'])) { parse_str($parsedUri['query'], $queryArray); } + return $queryArray; } private function getRequestType() { - // is the request a get or post? - if (empty($_POST)) { - return 'get'; - } else { - return 'post'; - } + return empty($_POST) ? 'get' : 'post'; } + /** + * Resolve the current path to a controller string. + * First tries an exact match, then falls back to matching parameterized + * routes like "/users/{id}". + */ private function findController() { - - // one to one match - if (array_key_exists($this->path, $this->routes)) { - if (!empty($this->routes[$this->path][$this->requestType])) { - return $this->routes[$this->path][$this->requestType]; - } + $exactMatch = $this->findExactMatch(); + if ($exactMatch !== null) { + return $exactMatch; } - foreach ($this->routes as $key => $value) { - // Check if key contains a curly bracket, if not continue. We already checked above. - if (strpos($key, '{') === false) continue; - - // Remove everything after the curly bracket, from key - $keyPath = substr($key, 0, strpos($key, '{')); - - //see if keyPath matches the first characters of $this->path, only the first characters have to match - if (strpos($this->path, $keyPath) === 0) { - - // We have a potential match. Now check if the parameter count is equal - $keyParams = explode('/', $key); - $pathParams = explode('/', $this->path); - $keyParamCount = count($keyParams); - $pathParamCount = count($pathParams); - if ($keyParamCount === $pathParamCount) { - for ($i=0; $i < $pathParamCount; $i++) { - if (strpos($keyParams[$i], '{') !== false) { - $keyParams[$i] = substr($keyParams[$i], 1, -1); - $this->parameters[$keyParams[$i]] = $pathParams[$i]; - return $this->routes[$key][$this->requestType]; - } - } - } - } + $paramMatch = $this->findParameterizedMatch(); + if ($paramMatch !== null) { + return $paramMatch; } - + return '404'; - } - // checks if the file exists, sets file path + private function findExactMatch() { + if (array_key_exists($this->path, $this->routes) + && !empty($this->routes[$this->path][$this->requestType]) + ) { + return $this->routes[$this->path][$this->requestType]; + } + + return null; + } + + /** + * Loop over parameterized routes (those containing "{") looking for the + * first one whose static prefix matches the path AND whose segment count + * matches. As soon as such a route is found, control is handed off to + * extractControllerFromMatch(), which returns immediately (even with a + * `null` controller) -- this mirrors the original code's behavior of + * stopping the search on the first prefix+segment-count match, rather + * than continuing on to try other candidate routes. + */ + private function findParameterizedMatch() { + foreach ($this->routes as $routePattern => $methods) { + // Only parameterized routes (containing "{") are relevant here; + // plain routes were already checked in findExactMatch(). + if (strpos($routePattern, '{') === false) { + continue; + } + + if (!$this->routeMatchesPath($routePattern)) { + continue; + } + + if (!$this->segmentCountsMatch($routePattern)) { + continue; + } + + // Original behavior: once we find a route with a matching prefix + // and segment count, we commit to it -- even if it turns out + // there's no handler for the current request method. + return $this->extractControllerFromMatch($routePattern, $methods); + } + + return null; + } + + /** + * Quick check: does the static portion of the route pattern (everything + * before the first "{") prefix-match the current path? + */ + private function routeMatchesPath($routePattern) { + $staticPrefix = substr($routePattern, 0, strpos($routePattern, '{')); + + return strpos($this->path, $staticPrefix) === 0; + } + + private function segmentCountsMatch($routePattern) { + $patternSegments = explode('/', $routePattern); + $pathSegments = explode('/', $this->path); + + return count($patternSegments) === count($pathSegments); + } + + /** + * Extracts named parameters into $this->parameters and returns the + * controller for the current request type (or null if that method + * isn't defined for this route). + * + * Matches the original quirk: only the FIRST "{param}" segment found + * gets added to $this->parameters, and the method returns right away -- + * any additional "{param}" segments later in the pattern are never + * processed. This is preserved as-is rather than "fixed", since + * behavior should stay unchanged. + */ + private function extractControllerFromMatch($routePattern, $methods) { + $patternSegments = explode('/', $routePattern); + $pathSegments = explode('/', $this->path); + + foreach ($patternSegments as $i => $segment) { + if (strpos($segment, '{') !== false) { + $paramName = substr($segment, 1, -1); // strip { } + $this->parameters[$paramName] = $pathSegments[$i]; + + return $methods[$this->requestType] ?? null; + } + } + + return null; + } + + /** + * Resolve the controller string to an actual file path, falling back to a 404 controller. + */ private function setRouteFile() { - if (str_starts_with($this->controller, 'NOVACONIUM')) { $trimmed = substr($this->controller, strlen('NOVACONIUM/')); - $cp = \FRAMEWORKPATH . '/controllers/' . $trimmed . '.php'; + $controllerPath = \FRAMEWORKPATH . '/controllers/' . $trimmed . '.php'; } else { - $cp = \BASEPATH . '/App/controllers/' . $this->controller . '.php'; + $controllerPath = \BASEPATH . '/App/controllers/' . $this->controller . '.php'; } - if (file_exists($cp)) { - return $cp; - } else { - //Check if 404 exits - if (file_exists( \BASEPATH . '/App/controllers/404.php')) { - return \BASEPATH . '/App/controllers/404.php'; - } else { - return \FRAMEWORKPATH . '/controllers/404.php'; - } + if (file_exists($controllerPath)) { + return $controllerPath; } + + if (file_exists(\BASEPATH . '/App/controllers/404.php')) { + return \BASEPATH . '/App/controllers/404.php'; + } + + return \FRAMEWORKPATH . '/controllers/404.php'; } public function debug() { @@ -134,9 +200,7 @@ class Router { echo 'Parameters
' . print_r($this->parameters, true) . '
'; echo 'Routes
' . print_r($this->routes, true) . '
'; echo ''; - + die(); } - - -} \ No newline at end of file +}