1 Commits

Author SHA1 Message Date
230476e8e5 Claude redid the router 2026-06-30 01:35:55 -07:00
2 changed files with 241 additions and 76 deletions

101
docs/Router.md Normal file
View File

@@ -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'];
```

View File

@@ -1,5 +1,6 @@
<?php <?php
namespace Novaconium; namespace Novaconium;
class Router { class Router {
public $routes = []; public $routes = [];
public $query = []; public $query = [];
@@ -18,29 +19,30 @@ class Router {
$this->controllerPath = $this->setRouteFile(); $this->controllerPath = $this->setRouteFile();
} }
/**
* Merge framework routes with app-defined routes (app routes can override framework routes).
*/
private function loadRoutes() { private function loadRoutes() {
require_once(\FRAMEWORKPATH . '/config/routes.php'); require_once(\FRAMEWORKPATH . '/config/routes.php');
// Check if Path exists
if (file_exists(\BASEPATH . '/App/routes.php')) { if (file_exists(\BASEPATH . '/App/routes.php')) {
require_once(\BASEPATH . '/App/routes.php'); require_once(\BASEPATH . '/App/routes.php');
} }
$routes = array_merge((array)$routes, (array)$framework_routes);
return $routes; return array_merge((array)$routes, (array)$framework_routes);
} }
/**
* Normalize the request path: strip query string, trailing slash, and anything after "&".
*/
private function preparePath() { private function preparePath() {
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
//homepage
if ($path === '/') { if ($path === '/') {
return $path; return $path;
} }
// remove empty directory path $path = rtrim($path, '/');
$path = rtrim($path, '/'); // remove trailing slash
//remove anything after and including ampersand
$path = preg_replace('/&.+$/', '', $path); $path = preg_replace('/&.+$/', '', $path);
return $path; return $path;
@@ -49,82 +51,146 @@ class Router {
private function prepareQuery() { private function prepareQuery() {
$parsedUri = parse_url($_SERVER['REQUEST_URI']); $parsedUri = parse_url($_SERVER['REQUEST_URI']);
$queryArray = []; $queryArray = [];
if (isset($parsedUri['query'])) { if (isset($parsedUri['query'])) {
parse_str($parsedUri['query'], $queryArray); parse_str($parsedUri['query'], $queryArray);
} }
return $queryArray; return $queryArray;
} }
private function getRequestType() { private function getRequestType() {
// is the request a get or post? return empty($_POST) ? 'get' : 'post';
if (empty($_POST)) {
return 'get';
} else {
return '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() { private function findController() {
$exactMatch = $this->findExactMatch();
// one to one match if ($exactMatch !== null) {
if (array_key_exists($this->path, $this->routes)) { return $exactMatch;
if (!empty($this->routes[$this->path][$this->requestType])) {
return $this->routes[$this->path][$this->requestType];
}
} }
foreach ($this->routes as $key => $value) { $paramMatch = $this->findParameterizedMatch();
// Check if key contains a curly bracket, if not continue. We already checked above. if ($paramMatch !== null) {
if (strpos($key, '{') === false) continue; return $paramMatch;
// 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];
}
}
}
}
} }
return '404'; return '404';
} }
// checks if the file exists, sets file path private function findExactMatch() {
private function setRouteFile() { 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')) { if (str_starts_with($this->controller, 'NOVACONIUM')) {
$trimmed = substr($this->controller, strlen('NOVACONIUM/')); $trimmed = substr($this->controller, strlen('NOVACONIUM/'));
$cp = \FRAMEWORKPATH . '/controllers/' . $trimmed . '.php'; $controllerPath = \FRAMEWORKPATH . '/controllers/' . $trimmed . '.php';
} else { } else {
$cp = \BASEPATH . '/App/controllers/' . $this->controller . '.php'; $controllerPath = \BASEPATH . '/App/controllers/' . $this->controller . '.php';
}
if (file_exists($controllerPath)) {
return $controllerPath;
} }
if (file_exists($cp)) {
return $cp;
} else {
//Check if 404 exits
if (file_exists(\BASEPATH . '/App/controllers/404.php')) { if (file_exists(\BASEPATH . '/App/controllers/404.php')) {
return \BASEPATH . '/App/controllers/404.php'; return \BASEPATH . '/App/controllers/404.php';
} else { }
return \FRAMEWORKPATH . '/controllers/404.php'; return \FRAMEWORKPATH . '/controllers/404.php';
} }
}
}
public function debug() { public function debug() {
echo '<div id="router-debug-container" class="debug">'; echo '<div id="router-debug-container" class="debug">';
@@ -137,6 +203,4 @@ class Router {
die(); die();
} }
} }