'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 Route or Ajax_Endpoint attribute $has_route = false; $has_ajax_endpoint = false; $route_pattern = null; if (isset($method_info['attributes'])) { foreach ($method_info['attributes'] as $attr_name => $attr_instances) { if ($attr_name === 'Route' || str_ends_with($attr_name, '\\Route')) { $has_route = true; // Get the route pattern from the first instance if (!empty($attr_instances)) { $route_args = $attr_instances[0]; $route_pattern = $route_args[0] ?? ($route_args['pattern'] ?? null); } break; } 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; } if (!$has_route) { // Not a controller method with Route/Ajax - check if it's a SPA action class return static::_try_spa_action_route($class_name, $params_array); } if (!$route_pattern) { throw new Rsx_Caller_Exception("Route attribute on {$class_name}::{$action_name} must have a pattern"); } // Generate URL from pattern return static::_generate_url_from_pattern($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"); } // Get the file path for this JS class try { $file_path = Manifest::js_find_class($class_name); } catch (\RuntimeException $e) { throw new Rsx_Caller_Exception("SPA action class {$class_name} not found in manifest"); } // Get file metadata which contains decorator information try { $file_data = Manifest::get_file($file_path); } catch (\RuntimeException $e) { throw new Rsx_Caller_Exception("File metadata not found for SPA action {$class_name}"); } // Extract route pattern from decorators // JavaScript files have 'decorators' array in their metadata // Format: [[0 => 'route', 1 => ['/contacts']], ...] $route_pattern = null; if (isset($file_data['decorators']) && is_array($file_data['decorators'])) { foreach ($file_data['decorators'] as $decorator) { // Decorator format: [0 => 'decorator_name', 1 => [arguments]] if (isset($decorator[0]) && $decorator[0] === 'route') { // First argument is the route pattern if (isset($decorator[1][0])) { $route_pattern = $decorator[1][0]; break; } } } } if (!$route_pattern) { throw new Rsx_Caller_Exception("SPA action {$class_name} must have @route() decorator with pattern"); } // Generate URL from pattern using same logic as regular routes return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, '(SPA action)'); } /** * 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); } } }