Files
rspade_system/app/RSpade/Core/Rsx.php
root 29c657f7a7 Exclude tests directory from framework publish
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>
2025-12-25 03:59:58 +00:00

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);
}
}
}