Add 100+ automated unit tests from .expect file specifications Add session system test Add rsx:constants:regenerate command test Add rsx:logrotate command test Add rsx:clean command test Add rsx:manifest:stats command test Add model enum system test Add model mass assignment prevention test Add rsx:check command test Add migrate:status command test 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
557 lines
19 KiB
PHP
557 lines
19 KiB
PHP
<?php
|
|
/**
|
|
* CODING CONVENTION:
|
|
* This file follows the coding convention where variable_names and function_names
|
|
* use snake_case (underscore_wherever_possible).
|
|
*
|
|
* @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
|
|
*/
|
|
|
|
namespace App\RSpade\Core;
|
|
|
|
use RuntimeException;
|
|
use App\RSpade\Core\Debug\Rsx_Caller_Exception;
|
|
use App\RSpade\Core\Events\Event_Registry;
|
|
use App\RSpade\Core\Manifest\Manifest;
|
|
|
|
/**
|
|
* Core RSX framework utility class
|
|
*
|
|
* Provides static utility methods for the RSX framework including
|
|
* flash messages and other core functionality.
|
|
*/
|
|
class Rsx
|
|
{
|
|
/**
|
|
* Current controller being executed
|
|
* @var string|null
|
|
*/
|
|
protected static $current_controller = null;
|
|
|
|
/**
|
|
* Current action being executed
|
|
* @var string|null
|
|
*/
|
|
protected static $current_action = null;
|
|
|
|
/**
|
|
* Current request params
|
|
* @var array|null
|
|
*/
|
|
protected static $current_params = null;
|
|
|
|
/**
|
|
* Current route type ('spa' or 'standard')
|
|
* @var string|null
|
|
*/
|
|
protected static $current_route_type = null;
|
|
|
|
/**
|
|
* Set the current controller and action being executed
|
|
*
|
|
* @param string $controller_class The controller class name
|
|
* @param string $action_method The action method name
|
|
* @param array $params Optional request params to store
|
|
* @param string|null $route_type Route type ('spa' or 'standard')
|
|
*/
|
|
public static function _set_current_controller_action($controller_class, $action_method, array $params = [], $route_type = null)
|
|
{
|
|
// Extract just the class name without namespace
|
|
$parts = explode('\\', $controller_class);
|
|
$class_name = end($parts);
|
|
|
|
static::$current_controller = $class_name;
|
|
static::$current_action = $action_method;
|
|
static::$current_params = $params;
|
|
static::$current_route_type = $route_type;
|
|
}
|
|
|
|
/**
|
|
* Get the current controller class name
|
|
*
|
|
* @return string|null The current controller class or null if not set
|
|
*/
|
|
public static function get_current_controller()
|
|
{
|
|
return static::$current_controller;
|
|
}
|
|
|
|
/**
|
|
* Get the current action method name
|
|
*
|
|
* @return string|null The current action method or null if not set
|
|
*/
|
|
public static function get_current_action()
|
|
{
|
|
return static::$current_action;
|
|
}
|
|
|
|
/**
|
|
* Get the current request params
|
|
*
|
|
* @return array|null The current request params or null if not set
|
|
*/
|
|
public static function get_current_params()
|
|
{
|
|
return static::$current_params;
|
|
}
|
|
|
|
/**
|
|
* Check if current route is a SPA route
|
|
*
|
|
* @return bool True if current route type is 'spa', false otherwise
|
|
*/
|
|
public static function is_spa()
|
|
{
|
|
return static::$current_route_type === 'spa';
|
|
}
|
|
|
|
/**
|
|
* Clear the current controller and action tracking
|
|
*/
|
|
public static function _clear_current_controller_action()
|
|
{
|
|
static::$current_controller = null;
|
|
static::$current_action = null;
|
|
static::$current_params = null;
|
|
static::$current_route_type = null;
|
|
}
|
|
|
|
// Flash alert methods have been removed - use Flash class instead:
|
|
// Flash_Alert::success($message)
|
|
// Flash_Alert::error($message)
|
|
// Flash_Alert::info($message)
|
|
// Flash_Alert::warning($message)
|
|
//
|
|
// See: /system/app/RSpade/Core/Flash/Flash.php
|
|
// See: /system/app/RSpade/Core/Flash/CLAUDE.md
|
|
|
|
/**
|
|
* Generate URL for a controller route
|
|
*
|
|
* This method generates URLs for controller actions by looking up route patterns
|
|
* and replacing parameters. It handles both regular routes and Ajax endpoints.
|
|
*
|
|
* Placeholder Routes:
|
|
* When the action starts with '#' (e.g., '#index', '#show'), it indicates a placeholder/unimplemented
|
|
* route for scaffolding purposes. These skip validation and return "#" to allow incremental
|
|
* development without requiring all controllers to exist.
|
|
*
|
|
* Usage examples:
|
|
* ```php
|
|
* // Controller route (defaults to 'index' method)
|
|
* $url = Rsx::Route('Frontend_Index_Controller');
|
|
* // Returns: /dashboard
|
|
*
|
|
* // Controller route with explicit method
|
|
* $url = Rsx::Route('Frontend_Client_View_Controller::view', 123);
|
|
* // Returns: /clients/view/123
|
|
*
|
|
* // SPA action route
|
|
* $url = Rsx::Route('Contacts_Index_Action');
|
|
* // Returns: /contacts
|
|
*
|
|
* // Route with integer parameter (sets 'id')
|
|
* $url = Rsx::Route('Contacts_View_Action', 123);
|
|
* // Returns: /contacts/123
|
|
*
|
|
* // Route with named parameters (array)
|
|
* $url = Rsx::Route('Contacts_View_Action', ['id' => 'C001']);
|
|
* // Returns: /contacts/C001
|
|
*
|
|
* // Route with required and query parameters
|
|
* $url = Rsx::Route('Contacts_View_Action', [
|
|
* 'id' => 'C001',
|
|
* 'tab' => 'history'
|
|
* ]);
|
|
* // Returns: /contacts/C001?tab=history
|
|
*
|
|
* // Placeholder route for scaffolding (doesn't need to exist)
|
|
* $url = Rsx::Route('Future_Feature_Controller::#index');
|
|
* // Returns: #
|
|
* ```
|
|
*
|
|
* @param string $action Controller class, SPA action, or "Class::method". Defaults to 'index' method if not specified.
|
|
* @param int|array|\stdClass|null $params Route parameters. Integer sets 'id', array/object provides named params.
|
|
* @return string The generated URL
|
|
* @throws RuntimeException If class doesn't exist, isn't a controller/action, method doesn't exist, or lacks Route attribute
|
|
*/
|
|
public static function Route($action, $params = null)
|
|
{
|
|
// Parse action into class_name and action_name
|
|
// Format: "Controller_Name" or "Controller_Name::method_name" or "Spa_Action_Name"
|
|
if (str_contains($action, '::')) {
|
|
[$class_name, $action_name] = explode('::', $action, 2);
|
|
} else {
|
|
$class_name = $action;
|
|
$action_name = 'index';
|
|
}
|
|
|
|
// Normalize params to array
|
|
$params_array = [];
|
|
if (is_int($params)) {
|
|
$params_array = ['id' => $params];
|
|
} elseif (is_array($params)) {
|
|
$params_array = $params;
|
|
} elseif ($params instanceof \stdClass) {
|
|
$params_array = (array) $params;
|
|
} elseif ($params !== null) {
|
|
throw new RuntimeException("Params must be integer, array, stdClass, or null");
|
|
}
|
|
|
|
// Placeholder route: action starts with # means unimplemented/scaffolding
|
|
// Skip all validation and return placeholder
|
|
if (str_starts_with($action_name, '#')) {
|
|
return '#';
|
|
}
|
|
|
|
// Try to find the class in the manifest
|
|
try {
|
|
$metadata = Manifest::php_get_metadata_by_class($class_name);
|
|
} catch (RuntimeException $e) {
|
|
// Not found as PHP class - might be a SPA action, try that instead
|
|
return static::_try_spa_action_route($class_name, $params_array);
|
|
}
|
|
|
|
// Verify it extends Rsx_Controller_Abstract
|
|
$extends = $metadata['extends'] ?? '';
|
|
$is_controller = false;
|
|
|
|
if ($extends === 'Rsx_Controller_Abstract') {
|
|
$is_controller = true;
|
|
} else {
|
|
// Check if it extends a class that extends Rsx_Controller_Abstract
|
|
$current_class = $extends;
|
|
$max_depth = 10;
|
|
|
|
while ($current_class && $max_depth-- > 0) {
|
|
try {
|
|
$parent_metadata = Manifest::php_get_metadata_by_class($current_class);
|
|
if (($parent_metadata['extends'] ?? '') === 'Rsx_Controller_Abstract') {
|
|
$is_controller = true;
|
|
break;
|
|
}
|
|
$current_class = $parent_metadata['extends'] ?? '';
|
|
} catch (RuntimeException $e) {
|
|
// Check if parent is the abstract controller with FQCN
|
|
if ($current_class === 'Rsx_Controller_Abstract' ||
|
|
$current_class === 'App\\RSpade\\Core\\Controller\\Rsx_Controller_Abstract') {
|
|
$is_controller = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$is_controller) {
|
|
throw new Rsx_Caller_Exception("Class {$class_name} must extend Rsx_Controller_Abstract");
|
|
}
|
|
|
|
// Check if method exists and has Route attribute
|
|
if (!isset($metadata['public_static_methods'][$action_name])) {
|
|
throw new Rsx_Caller_Exception("Method {$action_name} not found in class {$class_name}");
|
|
}
|
|
|
|
$method_info = $metadata['public_static_methods'][$action_name];
|
|
|
|
// All methods in public_static_methods are guaranteed to be static
|
|
// No need to check - but we assert for safety
|
|
if (!isset($method_info['static']) || !$method_info['static']) {
|
|
shouldnt_happen("Method {$class_name}::{$action_name} in public_static_methods is not static - extraction bug");
|
|
}
|
|
|
|
// Check for Ajax_Endpoint attribute
|
|
$has_ajax_endpoint = false;
|
|
|
|
if (isset($method_info['attributes'])) {
|
|
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
|
|
if ($attr_name === 'Ajax_Endpoint' || str_ends_with($attr_name, '\\Ajax_Endpoint')) {
|
|
$has_ajax_endpoint = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If has Ajax_Endpoint, return AJAX route URL (no param substitution)
|
|
if ($has_ajax_endpoint) {
|
|
$ajax_url = '/_ajax/' . urlencode($class_name) . '/' . urlencode($action_name);
|
|
// Add query params if provided
|
|
if (!empty($params_array)) {
|
|
$ajax_url .= '?' . http_build_query($params_array);
|
|
}
|
|
return $ajax_url;
|
|
}
|
|
|
|
// Look up routes in manifest using routes_by_target
|
|
$target = $class_name . '::' . $action_name;
|
|
$manifest = Manifest::get_full_manifest();
|
|
|
|
if (!isset($manifest['data']['routes_by_target'][$target])) {
|
|
// Not a controller method with Route - check if it's a SPA action class
|
|
return static::_try_spa_action_route($class_name, $params_array);
|
|
}
|
|
|
|
$routes = $manifest['data']['routes_by_target'][$target];
|
|
|
|
// Select best matching route based on provided parameters
|
|
$selected_route = static::_select_best_route($routes, $params_array);
|
|
|
|
if (!$selected_route) {
|
|
throw new Rsx_Caller_Exception(
|
|
"No suitable route found for {$class_name}::{$action_name} with provided parameters. " .
|
|
"Available routes: " . implode(', ', array_column($routes, 'pattern'))
|
|
);
|
|
}
|
|
|
|
// Generate URL from selected pattern
|
|
return static::_generate_url_from_pattern($selected_route['pattern'], $params_array, $class_name, $action_name);
|
|
}
|
|
|
|
/**
|
|
* Try to generate URL for a SPA action class
|
|
* Called when class lookup fails for controller - checks if it's a JavaScript SPA action
|
|
*
|
|
* @param string $class_name The class name (might be a JS SPA action)
|
|
* @param array $params_array Parameters for URL generation
|
|
* @return string The generated URL
|
|
* @throws Rsx_Caller_Exception If not a valid SPA action or route not found
|
|
*/
|
|
protected static function _try_spa_action_route(string $class_name, array $params_array): string
|
|
{
|
|
// Check if this is a JavaScript class that extends Spa_Action
|
|
try {
|
|
$is_spa_action = Manifest::js_is_subclass_of($class_name, 'Spa_Action');
|
|
} catch (\RuntimeException $e) {
|
|
// Not a JS class or not found
|
|
throw new Rsx_Caller_Exception("Class {$class_name} must extend Rsx_Controller_Abstract or Spa_Action");
|
|
}
|
|
|
|
if (!$is_spa_action) {
|
|
throw new Rsx_Caller_Exception("JavaScript class {$class_name} must extend Spa_Action to generate routes");
|
|
}
|
|
|
|
// Look up routes in manifest using routes_by_target
|
|
$manifest = Manifest::get_full_manifest();
|
|
|
|
if (!isset($manifest['data']['routes_by_target'][$class_name])) {
|
|
throw new Rsx_Caller_Exception("SPA action {$class_name} has no registered routes in manifest");
|
|
}
|
|
|
|
$routes = $manifest['data']['routes_by_target'][$class_name];
|
|
|
|
// Select best matching route based on provided parameters
|
|
$selected_route = static::_select_best_route($routes, $params_array);
|
|
|
|
if (!$selected_route) {
|
|
throw new Rsx_Caller_Exception(
|
|
"No suitable route found for SPA action {$class_name} with provided parameters. " .
|
|
"Available routes: " . implode(', ', array_column($routes, 'pattern'))
|
|
);
|
|
}
|
|
|
|
// Generate URL from selected pattern
|
|
return static::_generate_url_from_pattern($selected_route['pattern'], $params_array, $class_name, '(SPA action)');
|
|
}
|
|
|
|
/**
|
|
* Select the best matching route from available routes based on provided parameters
|
|
*
|
|
* Selection algorithm:
|
|
* 1. Filter routes where all required parameters can be satisfied by provided params
|
|
* 2. Among satisfiable routes, prioritize those with MORE parameters (more specific)
|
|
* 3. If tie, any route works (deterministic by using first match)
|
|
*
|
|
* @param array $routes Array of route data from manifest
|
|
* @param array $params_array Provided parameters
|
|
* @return array|null Selected route data or null if none match
|
|
*/
|
|
protected static function _select_best_route(array $routes, array $params_array): ?array
|
|
{
|
|
$satisfiable = [];
|
|
|
|
foreach ($routes as $route) {
|
|
$pattern = $route['pattern'];
|
|
|
|
// Extract required parameters from pattern
|
|
$required_params = [];
|
|
if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $pattern, $matches)) {
|
|
$required_params = $matches[1];
|
|
}
|
|
|
|
// Check if all required parameters are provided
|
|
$can_satisfy = true;
|
|
foreach ($required_params as $required) {
|
|
if (!array_key_exists($required, $params_array)) {
|
|
$can_satisfy = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($can_satisfy) {
|
|
$satisfiable[] = [
|
|
'route' => $route,
|
|
'param_count' => count($required_params),
|
|
];
|
|
}
|
|
}
|
|
|
|
if (empty($satisfiable)) {
|
|
return null;
|
|
}
|
|
|
|
// Sort by parameter count descending (most parameters first)
|
|
usort($satisfiable, function ($a, $b) {
|
|
return $b['param_count'] <=> $a['param_count'];
|
|
});
|
|
|
|
// Return the route with the most parameters
|
|
return $satisfiable[0]['route'];
|
|
}
|
|
|
|
/**
|
|
* Generate URL from route pattern by replacing parameters
|
|
*
|
|
* @param string $pattern The route pattern (e.g., '/users/:id/view')
|
|
* @param array $params Parameters to fill into the route
|
|
* @param string $class_name Controller class name (for error messages)
|
|
* @param string $action_name Action name (for error messages)
|
|
* @return string The generated URL
|
|
* @throws RuntimeException If required parameters are missing
|
|
*/
|
|
protected static function _generate_url_from_pattern($pattern, $params, $class_name, $action_name)
|
|
{
|
|
// Extract required parameters from the pattern
|
|
$required_params = [];
|
|
if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $pattern, $matches)) {
|
|
$required_params = $matches[1];
|
|
}
|
|
|
|
// Check for required parameters
|
|
$missing = [];
|
|
foreach ($required_params as $required) {
|
|
if (!array_key_exists($required, $params)) {
|
|
$missing[] = $required;
|
|
}
|
|
}
|
|
|
|
if (!empty($missing)) {
|
|
throw new RuntimeException(
|
|
"Required parameters [" . implode(', ', $missing) . "] are missing for route " .
|
|
"{$pattern} on {$class_name}::{$action_name}"
|
|
);
|
|
}
|
|
|
|
// Build the URL by replacing parameters
|
|
$url = $pattern;
|
|
$used_params = [];
|
|
|
|
foreach ($required_params as $param_name) {
|
|
$value = $params[$param_name];
|
|
// URL encode the value
|
|
$encoded_value = urlencode($value);
|
|
$url = str_replace(':' . $param_name, $encoded_value, $url);
|
|
$used_params[$param_name] = true;
|
|
}
|
|
|
|
// Collect any extra parameters for query string
|
|
$query_params = [];
|
|
foreach ($params as $key => $value) {
|
|
if (!isset($used_params[$key])) {
|
|
$query_params[$key] = $value;
|
|
}
|
|
}
|
|
|
|
// Append query string if there are extra parameters
|
|
if (!empty($query_params)) {
|
|
$url .= '?' . http_build_query($query_params);
|
|
}
|
|
|
|
return $url;
|
|
}
|
|
|
|
/**
|
|
* Trigger a filter event - data passes through chain of handlers
|
|
*
|
|
* Each handler receives the result of the previous handler and returns
|
|
* the modified data. Use for transforming data through a pipeline.
|
|
*
|
|
* Example:
|
|
* ```php
|
|
* $params = Rsx::trigger_filter('file.upload.params', $params);
|
|
* ```
|
|
*
|
|
* @param string $event Event name
|
|
* @param mixed $data Data to pass through filter chain
|
|
* @return mixed Transformed data
|
|
*/
|
|
public static function trigger_filter(string $event, $data)
|
|
{
|
|
$handlers = Event_Registry::get_handlers($event);
|
|
|
|
foreach ($handlers as $handler) {
|
|
$data = $handler($data);
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Trigger a gate event - first non-true return value halts execution
|
|
*
|
|
* Use for authorization, validation, and permission checks.
|
|
* First handler that returns non-true halts the chain and returns that value.
|
|
*
|
|
* Example:
|
|
* ```php
|
|
* $result = Rsx::trigger_gate('file.upload.authorize', ['request' => $request]);
|
|
* if ($result !== true) {
|
|
* return $result; // Handler returned error response
|
|
* }
|
|
* ```
|
|
*
|
|
* @param string $event Event name
|
|
* @param mixed $data Data to pass to handlers
|
|
* @return true|mixed Returns true if all handlers pass, or first non-true result
|
|
*/
|
|
public static function trigger_gate(string $event, $data)
|
|
{
|
|
$handlers = Event_Registry::get_handlers($event);
|
|
|
|
foreach ($handlers as $handler) {
|
|
$result = $handler($data);
|
|
if ($result !== true) {
|
|
return $result; // First denial/error wins
|
|
}
|
|
}
|
|
|
|
return true; // All handlers passed
|
|
}
|
|
|
|
/**
|
|
* Trigger an action event - fire all handlers, ignore return values
|
|
*
|
|
* Use for logging, notifications, and other side effects.
|
|
* All handlers are called regardless of their return values.
|
|
*
|
|
* Example:
|
|
* ```php
|
|
* Rsx::trigger_action('file.upload.complete', [
|
|
* 'attachment' => $attachment,
|
|
* 'request' => $request
|
|
* ]);
|
|
* ```
|
|
*
|
|
* @param string $event Event name
|
|
* @param mixed $data Data to pass to handlers
|
|
* @return void
|
|
*/
|
|
public static function trigger_action(string $event, $data): void
|
|
{
|
|
$handlers = Event_Registry::get_handlers($event);
|
|
|
|
foreach ($handlers as $handler) {
|
|
$handler($data);
|
|
}
|
|
}
|
|
}
|