1, 'api' => 2, 'files' => 3, 'custom' => 4, ]; /** * Dispatch a request to the appropriate handler * * @param string $url The URL to dispatch * @param string $method HTTP method (GET, POST, etc.) * @param array $extra_params Additional parameters to merge * @param Request|null $request Optional request object * @return mixed Response from handler, or null if no route found * @throws Exception */ public static function dispatch($url, $method = 'GET', $extra_params = [], ?Request $request = null) { // CRITICAL: No try/catch - let errors fail loud per coding conventions // Laravel's exception handler will handle all exceptions properly console_debug('BENCHMARK', "Dispatch started for: {$method} {$url}"); // Initialize manifest (handles all loading/rebuilding logic) console_debug('BENCHMARK', 'Initializing manifest'); Manifest::init(); console_debug('BENCHMARK', 'Manifest initialized'); // Validate Route attributes are not on classes (development mode only) static::__validate_route_attributes(); $request = $request ?? request(); // Custom session is handled by Session::init() in RsxAuth // Check if this is an asset request console_debug('BENCHMARK', 'Checking for static asset'); if (AssetHandler::is_asset_request($url)) { console_debug('BENCHMARK', "Serving static asset: {$url}"); return AssetHandler::serve($url, $request); } console_debug('BENCHMARK', 'Not a static asset, continuing route dispatch'); // HEAD requests should be treated as GET for route matching and handlers // Store the original method for response transformation $original_method = $method; $route_method = ($method === 'HEAD') ? 'GET' : $method; // Make the request appear as GET to all handlers if it's HEAD if ($method === 'HEAD' && $request) { $request->setMethod('GET'); } // Find matching route \Log::debug("Dispatcher: Looking for route: $url, method: $route_method"); console_debug('DISPATCH', 'Looking for route:', $url, 'method:', $route_method); console_debug('BENCHMARK', 'Finding matching route'); $route_match = static::__find_route($url, $route_method); console_debug('BENCHMARK', 'Route search complete'); if (!$route_match) { \Log::debug("Dispatcher: No route found for: $url"); console_debug('DISPATCH', 'No route found for:', $url); // Check if this matches the default route pattern: /_/{controller}/{action} if (preg_match('#^/_/([A-Za-z_][A-Za-z0-9_]*)/([A-Za-z_][A-Za-z0-9_]*)/?$#', $url, $matches)) { $controller_name = $matches[1]; $action_name = $matches[2]; console_debug('DISPATCH', 'Matched default route pattern:', $controller_name, '::', $action_name); // First try to find as PHP controller try { $metadata = Manifest::php_get_metadata_by_class($controller_name); $controller_fqcn = $metadata['fqcn']; // Verify it extends Rsx_Controller_Abstract if (!Manifest::php_is_subclass_of($controller_name, 'Rsx_Controller_Abstract')) { console_debug('DISPATCH', 'Class does not extend Rsx_Controller_Abstract:', $controller_name); return null; } // Verify the method exists and has a Route attribute if (!isset($metadata['public_static_methods'][$action_name])) { console_debug('DISPATCH', 'Method not found:', $action_name); return null; } $method_data = $metadata['public_static_methods'][$action_name]; if (!isset($method_data['attributes']['Route'])) { console_debug('DISPATCH', 'Method does not have Route attribute:', $action_name); return null; } // For POST requests: execute the action if ($route_method === 'POST') { // Collect parameters from GET query string only (not POST data) $params = array_merge($extra_params, $request->query->all()); // Create synthetic route match $route_match = [ 'class' => $controller_fqcn, 'method' => $action_name, 'params' => $params, 'pattern' => "/_/{$controller_name}/{$action_name}", 'require' => $method_data['attributes']['Auth'] ?? [] ]; // Continue with normal dispatch (will handle auth, pre_dispatch, etc.) // Fall through to normal route handling below } else { // For GET requests: redirect to the proper route $params = array_merge($extra_params, $request->query->all()); // Generate proper URL using Rsx::Route (signature: "Controller::method", $params) $proper_url = Rsx::Route($controller_name . '::' . $action_name, $params); console_debug('DISPATCH', 'Redirecting GET to proper route:', $proper_url); return redirect($proper_url, 302); } } catch (\RuntimeException $e) { console_debug('DISPATCH', 'Not a PHP controller, checking if SPA action:', $controller_name); // Not found as PHP controller - check if it's a SPA action try { $is_spa_action = Manifest::js_is_subclass_of($controller_name, 'Spa_Action'); if ($is_spa_action) { console_debug('DISPATCH', 'Found SPA action class:', $controller_name); // Get the file path for this JS class $file_path = Manifest::js_find_class($controller_name); // Get file metadata which contains decorator information $file_data = Manifest::get_file($file_path); if (!$file_data) { console_debug('DISPATCH', 'SPA action metadata not found:', $controller_name); return null; } // Extract route pattern from @route() decorator // Format: [[0 => 'route', 1 => ['/contacts']], ...] $route_pattern = null; if (isset($file_data['decorators']) && is_array($file_data['decorators'])) { foreach ($file_data['decorators'] as $decorator) { if (isset($decorator[0]) && $decorator[0] === 'route') { if (isset($decorator[1][0])) { $route_pattern = $decorator[1][0]; break; } } } } if ($route_pattern) { // Generate proper URL for the SPA action // Note: SPA actions use class name only (action_name is ignored for SPA routes) $params = array_merge($extra_params, $request->query->all()); $proper_url = Rsx::Route($controller_name, $params); console_debug('DISPATCH', 'Redirecting to SPA action route:', $proper_url); return redirect($proper_url, 302); } else { console_debug('DISPATCH', 'SPA action missing @route() decorator:', $controller_name); } } } catch (\RuntimeException $spa_e) { console_debug('DISPATCH', 'Not a SPA action either:', $controller_name); } return null; } } if (!$route_match) { // No route found - try Main pre_dispatch and unhandled_route hooks $params = array_merge(request()->all(), $extra_params); // First try Main pre_dispatch $main_classes = Manifest::php_get_extending('Main_Abstract'); foreach ($main_classes as $main_class) { if (isset($main_class['fqcn']) && $main_class['fqcn']) { $main_class_name = $main_class['fqcn']; if (method_exists($main_class_name, 'pre_dispatch')) { Debugger::console_debug('[DISPATCH]', 'Main::pre_dispatch'); $result = $main_class_name::pre_dispatch($request, $params); if ($result !== null) { $response = static::__build_response($result); return static::__transform_response($response, $original_method); } } } } // Then try unhandled_route hook foreach ($main_classes as $main_class) { if (isset($main_class['fqcn']) && $main_class['fqcn']) { $main_class_name = $main_class['fqcn']; if (method_exists($main_class_name, 'unhandled_route')) { $result = $main_class_name::unhandled_route($request, $params); if ($result !== null) { $response = static::__build_response($result); return static::__transform_response($response, $original_method); } } } } // Default 404 - return null to let Laravel handle it return null; } } // Extract route information $handler_class = $route_match['class']; $handler_method = $route_match['method']; $params = $route_match['params'] ?? []; // Merge parameters with correct priority order: // 1. Extra parameters (usually empty, lowest priority) // 2. GET parameters (from query string) // 3. URL route parameters (extracted from route pattern like :id) // Note: POST parameters are NOT included - controller $params only contains GET and route params $get_params = $request->query->all(); $params = array_merge($extra_params, $get_params, $params); // Add special parameters $params['_method'] = $method; $params['_route'] = $route_match['pattern'] ?? $url; $params['_handler'] = $handler_class; Debugger::console_debug('DISPATCH', 'Matched route to ' . $handler_class . '::' . $handler_method . ' params: ' . json_encode($params)); // Set current controller and action in Rsx for tracking $route_type = $route_match['type'] ?? 'standard'; \App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params, $route_type); // Load and validate handler class static::__load_handler_class($handler_class); // Permission checks are now handled manually in controller pre_dispatch() methods // and within individual route methods. See: php artisan rsx:man auth // A code quality rule (rsx:check) verifies auth checks exist. // Call pre_dispatch hooks $pre_dispatch_result = static::__call_pre_dispatch($handler_class, $handler_method, $params, $request); if ($pre_dispatch_result !== null) { // Pre-dispatch returned a response, build and return it $response = static::__build_response($pre_dispatch_result); return static::__transform_response($response, $original_method); } // Call the action method $result = static::__call_action($handler_class, $handler_method, $params, $request); // Convert result to response $response = static::__build_response($result); // Apply response transformations (HEAD body stripping, etc.) return static::__transform_response($response, $original_method); } /** * Resolve a URL to a route (public interface for code quality checks) * * @param string $url The URL to resolve (with or without query string) * @param string $method HTTP method (default GET) * @return array|null Array with 'class', 'method', 'params', 'pattern' or null */ public static function resolve_url_to_route($url, $method = 'GET') { // Initialize manifest if needed Manifest::init(); // Use internal method return static::__find_route($url, $method); } /** * Find a matching route for the URL and method * * @param string $url * @param string $method * @return array|null Route match with class, method, params */ protected static function __find_route($url, $method) { // Get routes from manifest $routes = Manifest::get_routes(); if (empty($routes)) { \Log::debug('Manifest::get_routes() returned empty array'); console_debug('DISPATCH', 'Warning: got 0 routes from Manifest::get_routes()'); return null; } \Log::debug('Manifest has ' . count($routes) . ' routes'); // Get all patterns and sort by priority $patterns = array_keys($routes); $patterns = RouteResolver::sort_by_priority($patterns); // Try to match each pattern foreach ($patterns as $pattern) { $route = $routes[$pattern]; // Check if HTTP method is supported if (!in_array($method, $route['methods'])) { continue; } // Try to match the URL $params = RouteResolver::match_with_query($url, $pattern); if ($params !== false) { // Found a match - verify the method has the required attribute $class_fqcn = $route['class']; $method_name = $route['method']; // Get method metadata from manifest $class_metadata = Manifest::php_get_metadata_by_fqcn($class_fqcn); $method_metadata = $class_metadata['public_static_methods'][$method_name] ?? null; if (!$method_metadata) { throw new \RuntimeException( "Route method not found in manifest: {$class_fqcn}::{$method_name}\n" . "Pattern: {$pattern}" ); } // Check for Route or SPA attribute $attributes = $method_metadata['attributes'] ?? []; $has_route = false; foreach ($attributes as $attr_name => $attr_instances) { if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route' || str_ends_with($attr_name, '\\SPA') || $attr_name === 'SPA') { $has_route = true; break; } } if (!$has_route) { throw new \RuntimeException( "Route method {$class_fqcn}::{$method_name} is missing required #[Route] or #[SPA] attribute.\n" . "Pattern: {$pattern}\n" . "File: {$route['file']}" ); } // Return route with params return [ 'type' => $route['type'], 'pattern' => $pattern, 'class' => $route['class'], 'method' => $route['method'], 'params' => $params, 'file' => $route['file'] ?? null, 'require' => $route['require'] ?? [], ]; } } // No match found return null; } /** * Load and validate handler class * * @param string $class_name * @throws Exception */ protected static function __load_handler_class($class_name) { // Use Manifest to verify the class exists // Check if this is already a FQCN (contains backslash) if (strpos($class_name, '\\') !== false) { // It's a FQCN, use php_get_metadata_by_fqcn try { $metadata = Manifest::php_get_metadata_by_fqcn($class_name); $fqcn = $metadata['fqcn']; } catch (RuntimeException $e) { throw new Exception("Handler class not found in manifest: {$class_name}"); } } else { // It's a simple name, try different approaches try { $metadata = Manifest::php_get_metadata_by_class($class_name); // Class exists in manifest, trigger autoloading by referencing the FQCN $fqcn = $metadata['fqcn']; // The autoloader will handle loading when we reference the class } catch (RuntimeException $e) { // Try with Rsx namespace prefix try { $metadata = Manifest::php_get_metadata_by_class('Rsx\\' . $class_name); $fqcn = $metadata['fqcn']; } catch (RuntimeException $e2) { throw new Exception("Handler class not found in manifest: {$class_name}"); } } } } /** * Process cache attributes * * @param array $attributes * @param array $params * @param mixed $result */ protected static function __process_cache($attributes, $params, $result) { // This is a simplified implementation // In production, this would integrate with Laravel's cache system if (!isset($attributes['App\\RSpade\\Core\\Attributes\\Cache'])) { return; } foreach ($attributes['App\\RSpade\\Core\\Attributes\\Cache'] as $cache) { if ($cache->is_enabled()) { $key = $cache->generate_key($params); Log::debug("Would cache result with key: {$key} for {$cache->ttl} seconds"); // Here we would actually cache the result } } } /** * Call pre_dispatch hook * * @param string $class_name * @param string $method_name * @param array $params * @param Request|null $request * @return mixed|null Returns non-null to halt dispatch with that response */ protected static function __call_pre_dispatch($class_name, $method_name, &$params, ?Request $request = null) { $request = $request ?? request(); // First, call pre_dispatch on Main classes (if any exist) $main_classes = Manifest::php_get_extending('Main_Abstract'); foreach ($main_classes as $main_class) { if (isset($main_class['fqcn']) && $main_class['fqcn']) { $main_class_name = $main_class['fqcn']; if (method_exists($main_class_name, 'pre_dispatch')) { $result = $main_class_name::pre_dispatch($request, $params); if ($result !== null) { return $result; } } } } // Then call pre_dispatch on the controller itself // Note: Controller pre_dispatch is handled in call_action for instance controllers // Only handle static pre_dispatch here for non-controller classes try { if (!static::__is_controller($class_name)) { $reflection = new ReflectionClass($class_name); if ($reflection->hasMethod('pre_dispatch')) { $pre_dispatch = $reflection->getMethod('pre_dispatch'); if ($pre_dispatch->isStatic() && $pre_dispatch->isPublic()) { $result = $pre_dispatch->invoke(null, $request, $params); // If pre_dispatch returns non-null, return that value if ($result !== null) { return $result; } } } } return null; } catch (Throwable $e) { Log::error("Pre-dispatch failed for {$class_name}: " . $e->getMessage()); return null; } } /** * Call the action method * * @param string $class_name * @param string $method_name * @param array $params * @param array $attributes * @param Request $request * @return mixed * @throws Exception */ protected static function __call_action($class_name, $method_name, $params, ?Request $request = null) { $request = $request ?? request(); $reflection = new ReflectionClass($class_name); if (!$reflection->hasMethod($method_name)) { throw new Exception("Method not found: {$class_name}::{$method_name}"); } $method = $reflection->getMethod($method_name); if (!$method->isPublic()) { throw new Exception("Method not public: {$class_name}::{$method_name}"); } // NOTE: Do NOT call _set_current_controller_action here - it's already been set // earlier in the dispatch flow with the correct route type. Calling it again // would overwrite the route type with null. // Check if this is a controller (all methods are static) if (static::__is_controller($class_name)) { // Call pre_dispatch if it exists if (method_exists($class_name, 'pre_dispatch')) { $result = $class_name::pre_dispatch($request, $params); if ($result !== null) { // Don't clear tracking - view might need it during rendering return $result; } } // Call the action method statically with request and params $result = $class_name::$method_name($request, $params); // Don't clear tracking - view needs it during rendering // The values will be overwritten on the next request return $result; } // For other handlers, check if static if (!$method->isStatic()) { // Create instance for non-static methods $instance = app()->make($class_name); $result = $method->invoke($instance, $request, $params); // Don't clear tracking - view needs it during rendering return $result; } // Call static method $result = $method->invoke(null, $request, $params); // Don't clear tracking - view needs it during rendering return $result; } /** * Check if class is a controller * * @param string $class_name * @return bool */ protected static function __is_controller($class_name) { try { // Check if it extends Rsx_Controller_Abstract using Manifest return \App\RSpade\Core\Manifest\Manifest::php_is_subclass_of( $class_name, 'App\\RSpade\\Core\\Controller\\Rsx_Controller_Abstract' ); } catch (Throwable $e) { return false; } } /** * Build response from handler result * * @param mixed $result * @param array $attributes * @return Response */ protected static function __build_response($result) { // Handle special RSX response types if ($result instanceof \App\RSpade\Core\Response\Rsx_Response_Abstract) { return static::__handle_special_response($result); } // If already a Response object (check Symfony base class to catch all response types) if ($result instanceof \Symfony\Component\HttpFoundation\Response) { return $result; } // Handle View objects if ($result instanceof \Illuminate\View\View || $result instanceof \Illuminate\Contracts\View\View) { return response($result); } // Handle array responses with type hints if (is_array($result) && isset($result['type'])) { return static::__build_typed_response($result); } // Check ResponseType attribute if (isset($attributes['App\\RSpade\\Core\\Attributes\\ResponseType'])) { $response_type = $attributes['App\\RSpade\\Core\\Attributes\\ResponseType'][0]; return static::__build_attribute_response($result, $response_type); } // Default: return as JSON return response()->json($result); } /** * Build response from typed result array * * @param array $result * @return Response */ protected static function __build_typed_response($result) { $type = $result['type']; $status = $result['status'] ?? 200; $headers = $result['headers'] ?? []; switch ($type) { case 'view': return response() ->view($result['view'], $result['data'] ?? [], $status) ->withHeaders($headers); case 'json': return response()->json($result['data'] ?? $result, $status, $headers); case 'redirect': $status = $result['status'] ?? 302; return response('', $status)->header('Location', $result['url']); case 'file': $response = response()->file($result['path'], $headers); if (isset($result['name'])) { $disposition = $result['disposition'] ?? 'attachment'; $response->header('Content-Disposition', "{$disposition}; filename=\"" . $result['name'] . '"'); } return $response; case 'error': abort($result['code'] ?? 500, $result['message'] ?? 'Server Error'); // no break case 'empty': return response('', $status, $headers); case 'stream': return response()->stream($result['callback'], $status, $headers); default: // Unknown type, return as JSON return response()->json($result, $status, $headers); } } /** * Handle special RSX response types for HTTP requests * * @param \App\RSpade\Core\Response\Rsx_Response_Abstract $response * @return \Illuminate\Http\Response */ protected static function __handle_special_response(\App\RSpade\Core\Response\Rsx_Response_Abstract $response) { $type = $response->get_type(); $reason = $response->get_reason(); $redirect_url = $response->get_redirect(); $details = $response->get_details(); // Handle fatal error - always throw exception if ($type === 'fatal') { $message = $reason; if (!empty($details)) { $message .= ' - ' . json_encode($details); } throw new Exception($message); } // Handle authentication required if ($type === \App\RSpade\Core\Ajax\Ajax::ERROR_AUTH_REQUIRED) { if ($redirect_url) { Rsx::flash_error($reason); return redirect($redirect_url); } throw new Exception($reason); } // Handle unauthorized if ($type === \App\RSpade\Core\Ajax\Ajax::ERROR_UNAUTHORIZED) { if ($redirect_url) { Rsx::flash_error($reason); return redirect($redirect_url); } throw new Exception($reason); } // Handle validation and not found errors if ($type === \App\RSpade\Core\Ajax\Ajax::ERROR_VALIDATION || $type === \App\RSpade\Core\Ajax\Ajax::ERROR_NOT_FOUND) { // Only redirect if this was a POST request if (request()->isMethod('POST')) { Rsx::flash_error($reason); // Redirect to same URL as GET request return redirect(request()->url()); } // Not a POST request, throw exception throw new Exception($reason); } // Unknown response type throw new Exception("Unknown RSX response type: {$type}"); } /** * Build response based on ResponseType attribute * * @param mixed $result * @param ResponseType $response_type * @return Response */ protected static function __build_attribute_response($result, $response_type) { $status = $response_type->status; $headers = $response_type->headers; switch ($response_type->type) { case 'json': return response()->json($result, $status, $headers); case 'html': if ($response_type->view) { return response()->view($response_type->view, $result, $status)->withHeaders($headers); } return response($result, $status, $headers)->header('Content-Type', 'text/html'); case 'xml': // Simple XML conversion $xml = static::__array_to_xml($result); return response($xml, $status, $headers)->header('Content-Type', 'application/xml'); case 'text': return response($result, $status, $headers)->header('Content-Type', 'text/plain'); default: return response()->json($result, $status, $headers); } } /** * Convert array to XML string * * @param array $data * @param string $root_element * @return string */ protected static function __array_to_xml($data, $root_element = 'response') { $xml = new SimpleXMLElement("<{$root_element}>"); static::__array_to_xml_recursive($data, $xml); return $xml->asXML(); } /** * Recursive helper for array to XML conversion * * @param array $data * @param SimpleXMLElement $xml */ protected static function __array_to_xml_recursive($data, &$xml) { foreach ($data as $key => $value) { if (is_numeric($key)) { $key = 'item' . $key; } if (is_array($value)) { $subnode = $xml->addChild($key); static::__array_to_xml_recursive($value, $subnode); } else { $xml->addChild($key, htmlspecialchars((string) $value)); } } } /** * Handle 404 not found * * @param string $url * @param string $method * @return Response */ protected static function __handle_not_found($url, $method) { Log::warning("Route not found: {$method} {$url}"); // Try to find a custom 404 handler $custom_404 = static::__find_route('/404', 'GET'); if ($custom_404) { try { $result = static::__call_action($custom_404['class'], $custom_404['method'], ['url' => $url, 'method' => $method]); return static::__build_response($result); } catch (Throwable $e) { Log::error('Custom 404 handler failed: ' . $e->getMessage()); } } // Default 404 response abort(404, "Route not found: {$url}"); } /** * Transform response based on original request method * * This centralized method handles all response transformations * including stripping body for HEAD requests * * @param mixed $response The response to transform * @param string $original_method The original HTTP method * @return mixed The transformed response */ protected static function __transform_response($response, $original_method) { // Session cookie handling moved to custom Session class // If this was originally a HEAD request, strip the body if ($original_method === 'HEAD' && $response instanceof \Symfony\Component\HttpFoundation\Response) { $response->setContent(''); } // Future transformations can be added here return $response; } /** * Set custom handler priorities * * @param array $priorities */ public static function set_handler_priorities($priorities) { static::$handler_priorities = $priorities; } /** * Get current handler priorities * * @return array */ public static function get_handler_priorities() { return static::$handler_priorities; } /** * Validate that Route attributes are not placed on classes * Only runs in non-production mode * * @throws RuntimeException if Route attributes found on classes */ protected static function __validate_route_attributes() { // Only validate in non-production mode if (app()->environment('production')) { return; } // Get all manifest entries $manifest = Manifest::get_all(); $errors = []; // Check each file for class-level Route attributes foreach ($manifest as $file_path => $metadata) { // Skip non-PHP files (controllers are PHP files) if (!isset($metadata['type']) || ($metadata['type'] !== 'php' && $metadata['type'] !== 'controller')) { continue; } // Check if this file has class-level attributes // Attributes are stored with simple names (e.g., "Route" not "App\RSpade\Core\Attributes\Route") if (isset($metadata['attributes']) && is_array($metadata['attributes']) && !empty($metadata['attributes'])) { foreach ($metadata['attributes'] as $attr_name => $attr_data) { // Check for Route-related attributes (simple names) if ($attr_name === 'Route' || $attr_name === 'Get' || $attr_name === 'Post' || $attr_name === 'Put' || $attr_name === 'Delete' || $attr_name === 'Patch') { $class_name = $metadata['class'] ?? 'Unknown'; $errors[] = [ 'file' => $file_path, 'class' => $class_name, 'attribute' => $attr_name, ]; } } } } // If errors found, throw fatal error with detailed message if (!empty($errors)) { $error_msg = "Route attributes should be assigned to static methods in a controller class, not as an attribute on the class itself.\n\n"; $error_msg .= "The following classes have Route attributes incorrectly placed on the class:\n\n"; foreach ($errors as $error) { $error_msg .= " File: {$error['file']}\n"; $error_msg .= " Class: {$error['class']}\n"; $error_msg .= " Attribute: #{$error['attribute']}\n\n"; } $error_msg .= "To fix this, move the Route attribute to a static method within the class.\n"; $error_msg .= "Example:\n"; $error_msg .= " class My_Controller extends Rsx_Controller_Abstract {\n"; $error_msg .= " #[Route('/path', methods: ['GET'])]\n"; $error_msg .= " public static function index(Request \$request, array \$params = []) {\n"; $error_msg .= " // ...\n"; $error_msg .= " }\n"; $error_msg .= " }\n"; throw new RuntimeException($error_msg); } } }