Files
rspade_system/app/RSpade/Core/Dispatch/Dispatcher.php
root 9ebcc359ae Fix code quality violations and enhance ROUTE-EXISTS-01 rule
Implement JQHTML function cache ID system and fix bundle compilation
Implement underscore prefix for system tables
Fix JS syntax linter to support decorators and grant exception to Task system
SPA: Update planning docs and wishlists with remaining features
SPA: Document Navigation API abandonment and future enhancements
Implement SPA browser integration with History API (Phase 1)
Convert contacts view page to SPA action
Convert clients pages to SPA actions and document conversion procedure
SPA: Merge GET parameters and update documentation
Implement SPA route URL generation in JavaScript and PHP
Implement SPA bootstrap controller architecture
Add SPA routing manual page (rsx:man spa)
Add SPA routing documentation to CLAUDE.md
Phase 4 Complete: Client-side SPA routing implementation
Update get_routes() consumers for unified route structure
Complete SPA Phase 3: PHP-side route type detection and is_spa flag
Restore unified routes structure and Manifest_Query class
Refactor route indexing and add SPA infrastructure
Phase 3 Complete: SPA route registration in manifest
Implement SPA Phase 2: Extract router code and test decorators
Rename Jqhtml_Component to Component and complete SPA foundation setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 17:48:15 +00:00

1456 lines
55 KiB
PHP
Executable File

<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Dispatch;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use ReflectionClass;
use RuntimeException;
use SimpleXMLElement;
use Throwable;
use App\RSpade\Core\Debug\Debugger;
use App\RSpade\Core\Dispatch\AssetHandler;
use App\RSpade\Core\Dispatch\RouteResolver;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Rsx;
/**
* Dispatcher - Step 3 of RSX Request Processing
*
* CALLED BY: RsxController::handle() with URL, method, and parameters
*
* WHAT IT DOES:
* 1. Initializes manifest (Manifest::init() handles loading/rebuilding as needed)
* 2. Checks if request is for static asset (serves directly if so)
* 3. Finds matching route using RouteResolver pattern matching
* 4. Loads and validates the handler class
* 5. Processes attributes (middleware, cache, rate limits, etc.) via AttributeProcessor
* 6. Calls optional pre_dispatch hook on handler
* 7. Executes the handler's action method
* 8. Builds appropriate response (JSON, HTML, redirect, etc.)
* 9. Runs post-processing for caching and other attributes
*
* FINAL STEPS:
* → For assets: Returns file response directly to client
* → For routes: Returns processed response (JSON/HTML/etc.) to client
* → For errors: Returns 404/500 error response to client
*/
class Dispatcher
{
// Manifest is now a static class - no instance needed
/**
* @var array Handler type priorities
*/
protected static $handler_priorities = [
'controllers' => 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
$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
$params = array_merge($extra_params, $request->query->all());
$proper_url = Rsx::Route($controller_name, $action_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'] ?? [];
// Check for SSR Full Page Cache (FPC) before any processing
$fpc_response = static::__check_ssr_fpc($url, $handler_class, $handler_method, $request);
if ($fpc_response !== null) {
return static::__transform_response($fpc_response, $original_method);
}
// 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);
// Check controller pre_dispatch Auth attributes
$pre_dispatch_requires = Manifest::get_pre_dispatch_requires($handler_class);
foreach ($pre_dispatch_requires as $require_attr) {
$result = static::__check_require($require_attr, $request, $params, $handler_class, 'pre_dispatch');
if ($result !== null) {
$response = static::__build_response($result);
return static::__transform_response($response, $original_method);
}
}
// Check route method Auth attributes
$route_requires = $route_match['require'] ?? [];
// Validate that at least one Auth exists (either on route or pre_dispatch)
if (empty($route_requires) && empty($pre_dispatch_requires)) {
throw new RuntimeException(
"Route method '{$handler_class}::{$handler_method}' is missing required #[\\Auth] attribute.\n\n" .
"All routes must specify access control using #[\\Auth('Permission::method()')].\n\n" .
"Examples:\n" .
" #[\\Auth('Permission::anybody()')] // Public access\n" .
" #[\\Auth('Permission::authenticated()')] // Must be logged in\n" .
" #[\\Auth('Permission::has_role(\"admin\")')] // Custom check with args\n\n" .
"Alternatively, add #[\\Auth] to pre_dispatch() to apply to all routes in this controller.\n\n" .
"To create a permission method, add to rsx/permission.php:\n" .
" public static function custom_check(Request \$request, array \$params): mixed {\n" .
" return RsxAuth::check(); // true = allow, false = deny\n" .
" }"
);
}
foreach ($route_requires as $require_attr) {
$result = static::__check_require($require_attr, $request, $params, $handler_class, $handler_method);
if ($result !== null) {
$response = static::__build_response($result);
return static::__transform_response($response, $original_method);
}
}
// 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 === 'response_auth_required') {
if ($redirect_url) {
Rsx::flash_error($reason);
return redirect($redirect_url);
}
throw new Exception($reason);
}
// Handle unauthorized
if ($type === 'response_unauthorized') {
if ($redirect_url) {
Rsx::flash_error($reason);
return redirect($redirect_url);
}
throw new Exception($reason);
}
// Handle form error
if ($type === 'response_form_error') {
// 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("<?xml version=\"1.0\"?><{$root_element}></{$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);
}
}
/**
* Check a Auth attribute and execute the permission check
*
* @param array $require_attr The Auth attribute arguments
* @param Request $request
* @param array $params
* @param string $handler_class For error messages
* @param string $handler_method For error messages
* @return mixed|null Returns response to halt dispatch, or null to continue
* @throws RuntimeException on parsing or execution errors
*/
protected static function __check_require(array $require_attr, Request $request, array $params, string $handler_class, string $handler_method)
{
// Extract parameters - first arg is callable string, rest are named params
$callable_str = $require_attr[0] ?? null;
$message = $require_attr['message'] ?? null;
$redirect = $require_attr['redirect'] ?? null;
$redirect_to = $require_attr['redirect_to'] ?? null;
if (!$callable_str) {
throw new RuntimeException(
"Auth attribute on {$handler_class}::{$handler_method} is missing callable string.\n" .
"Expected: #[Auth('Permission::method()')]\n" .
"Got: " . json_encode($require_attr)
);
}
if ($redirect && $redirect_to) {
throw new RuntimeException(
"Auth attribute on {$handler_class}::{$handler_method} cannot specify both 'redirect' and 'redirect_to'.\n" .
"Use either redirect: '/path' OR redirect_to: ['Controller', 'action']"
);
}
// Parse callable string - FATAL if parsing fails
[$class, $method, $args] = static::__parse_require_callable($callable_str, $handler_class, $handler_method);
// Verify permission class and method exist
if (!class_exists($class)) {
throw new RuntimeException(
"Permission class '{$class}' not found for Auth on {$handler_class}::{$handler_method}.\n" .
"Make sure the class exists and is loaded by the manifest."
);
}
if (!method_exists($class, $method)) {
throw new RuntimeException(
"Permission method '{$class}::{$method}' not found for Auth on {$handler_class}::{$handler_method}.\n" .
"Add this method to {$class}:\n" .
" public static function {$method}(Request \$request, array \$params): mixed {\n" .
" return true; // or false to deny\n" .
" }"
);
}
// Call permission method
try {
$result = $class::$method($request, $params, ...$args);
} catch (\Throwable $e) {
throw new RuntimeException(
"Permission check '{$class}::{$method}' threw exception for {$handler_class}::{$handler_method}:\n" .
$e->getMessage(),
0,
$e
);
}
// Handle result
if ($result === true || $result === null) {
return null; // Pass - continue to next check or route
}
if ($result instanceof \Symfony\Component\HttpFoundation\Response) {
return $result; // Custom response from permission method
}
// Permission failed - detect if Ajax context
$is_ajax = ($handler_class === 'App\\RSpade\\Core\\Dispatch\\Ajax_Endpoint_Controller') ||
str_starts_with($request->path(), '_ajax/') ||
str_starts_with($request->path(), '_fetch/');
if ($is_ajax) {
// Ajax context - return JSON error, ignore redirect parameters
return response()->json([
'success' => false,
'error' => $message ?? 'Permission denied',
'error_type' => 'permission_denied'
], 403);
}
// Regular HTTP context - handle redirect/message
if ($redirect_to) {
if (!is_array($redirect_to) || count($redirect_to) < 1) {
throw new RuntimeException(
"Auth redirect_to must be array ['Controller', 'action'] on {$handler_class}::{$handler_method}"
);
}
$action = $redirect_to[0];
if (isset($redirect_to[1]) && $redirect_to[1] !== 'index') {
$action .= '::' . $redirect_to[1];
}
$url = Rsx::Route($action);
if ($message) {
Rsx::flash_error($message);
}
return redirect($url);
}
if ($redirect) {
if ($message) {
Rsx::flash_error($message);
}
return redirect($redirect);
}
// Default: throw 403 Forbidden
throw new \Symfony\Component\HttpKernel\Exception\HttpException(403, $message ?? 'Forbidden');
}
/**
* Parse Auth callable string into class, method, and args
*
* Supports formats:
* - Permission::method()
* - Permission::method(3)
* - Permission::method("string")
* - Permission::method(1, "arg2", 3)
*
* FATAL on parse failure
*
* @param string $str Callable string
* @param string $handler_class For error messages
* @param string $handler_method For error messages
* @return array [class, method, args]
* @throws RuntimeException on parse failure
*/
protected static function __parse_require_callable(string $str, string $handler_class, string $handler_method): array
{
// Pattern: ClassName::method_name(args)
if (!preg_match('/^([A-Za-z_][A-Za-z0-9_\\\\]*)::([a-z_][a-z0-9_]*)\((.*)\)$/i', $str, $matches)) {
throw new RuntimeException(
"Failed to parse Auth callable on {$handler_class}::{$handler_method}.\n\n" .
"Invalid format: '{$str}'\n\n" .
"Expected format: 'ClassName::method_name()' or 'ClassName::method_name(arg1, arg2)'\n\n" .
"Examples:\n" .
" Permission::anybody()\n" .
" Permission::authenticated()\n" .
" Permission::has_role(\"admin\")\n" .
" Permission::has_permission(3, \"write\")"
);
}
$class = $matches[1];
$method = $matches[2];
$args_str = trim($matches[3]);
// Resolve class name if not fully qualified
if (strpos($class, '\\') === false) {
// Try to find the class using manifest discovery
try {
$metadata = Manifest::php_get_metadata_by_class($class);
if (isset($metadata['fqcn'])) {
$class = $metadata['fqcn'];
}
} catch (\RuntimeException $e) {
// Class not found in manifest - leave as-is and let class_exists check fail later with better error
}
}
$args = [];
if ($args_str !== '') {
$args = static::__parse_args($args_str, $handler_class, $handler_method, $str);
}
return [$class, $method, $args];
}
/**
* Parse argument list from Auth callable
*
* Supports: integers, quoted strings, simple values
* FATAL on parse failure
*
* @param string $args_str Argument list string
* @param string $handler_class For error messages
* @param string $handler_method For error messages
* @param string $full_callable For error messages
* @return array Parsed arguments
* @throws RuntimeException on parse failure
*/
protected static function __parse_args(string $args_str, string $handler_class, string $handler_method, string $full_callable): array
{
$args = [];
$current_arg = '';
$in_quotes = false;
$quote_char = null;
$escaped = false;
for ($i = 0; $i < strlen($args_str); $i++) {
$char = $args_str[$i];
if ($escaped) {
$current_arg .= $char;
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if (!$in_quotes && ($char === '"' || $char === "'")) {
$in_quotes = true;
$quote_char = $char;
continue;
}
if ($in_quotes && $char === $quote_char) {
$in_quotes = false;
$quote_char = null;
continue;
}
if (!$in_quotes && $char === ',') {
$args[] = static::__convert_arg_value(trim($current_arg), $handler_class, $handler_method, $full_callable);
$current_arg = '';
continue;
}
$current_arg .= $char;
}
if ($in_quotes) {
throw new RuntimeException(
"Failed to parse Auth arguments on {$handler_class}::{$handler_method}.\n\n" .
"Unclosed quote in: '{$full_callable}'\n\n" .
"Make sure all quoted strings are properly closed."
);
}
if ($current_arg !== '') {
$args[] = static::__convert_arg_value(trim($current_arg), $handler_class, $handler_method, $full_callable);
}
return $args;
}
/**
* Convert argument string to appropriate type
*
* @param string $value
* @param string $handler_class For error messages
* @param string $handler_method For error messages
* @param string $full_callable For error messages
* @return mixed
*/
protected static function __convert_arg_value(string $value, string $handler_class, string $handler_method, string $full_callable)
{
if ($value === '') {
throw new RuntimeException(
"Empty argument in Auth on {$handler_class}::{$handler_method}.\n" .
"Callable: '{$full_callable}'"
);
}
// Integer
if (preg_match('/^-?\d+$/', $value)) {
return (int) $value;
}
// Float
if (preg_match('/^-?\d+\.\d+$/', $value)) {
return (float) $value;
}
// Boolean
if ($value === 'true') {
return true;
}
if ($value === 'false') {
return false;
}
// Null
if ($value === 'null') {
return null;
}
// Everything else is a string
return $value;
}
/**
* Check for SSR Full Page Cache and serve if available
*
* @param string $url
* @param string $handler_class
* @param string $handler_method
* @param Request $request
* @return Response|null Returns response if FPC should be served, null otherwise
*/
protected static function __check_ssr_fpc(string $url, string $handler_class, string $handler_method, Request $request)
{
console_debug('SSR_FPC', "FPC check started for {$url}");
// Check if SSR FPC is enabled
if (!config('rsx.ssr_fpc.enabled', false)) {
console_debug('SSR_FPC', 'FPC disabled in config');
return null;
}
// Check if this is the FPC client (prevent infinite loop)
// FPC clients are identified by X-RSpade-FPC-Client: 1 header
if (isset($_SERVER['HTTP_X_RSPADE_FPC_CLIENT']) && $_SERVER['HTTP_X_RSPADE_FPC_CLIENT'] === '1') {
console_debug('SSR_FPC', 'Skipping FPC - is FPC client');
return null;
}
// Check if user has a session (only serve FPC to users without sessions)
if (\App\RSpade\Core\Session\Session::has_session()) {
console_debug('SSR_FPC', 'Skipping FPC - user has session');
return null;
}
console_debug('SSR_FPC', 'User has no session, continuing...');
// FEATURE DISABLED: SSR Full Page Cache is not yet complete
// This feature will be finished at a later point
// See docs.dev/SSR_FPC.md for implementation details and current state
// Disable by always returning false for Static_Page check
$has_static_page = false;
if (!$has_static_page) {
console_debug('SSR_FPC', 'SSR FPC feature is disabled - will be completed later');
return null;
}
// Check if user is authenticated (only serve FPC to unauthenticated users)
// TODO: Should use has_session() instead of is_logged_in(), but using is_logged_in() for now to simplify debugging
if (\App\RSpade\Core\Session\Session::is_logged_in()) {
return null;
}
// Strip GET parameters from URL for cache key
$clean_url = parse_url($url, PHP_URL_PATH) ?: $url;
// Get build key from manifest
$build_key = \App\RSpade\Core\Manifest\Manifest::get_build_key();
// Generate Redis cache key
$url_hash = sha1($clean_url);
$redis_key = "ssr_fpc:{$build_key}:{$url_hash}";
// Check if cache exists
try {
$cache_data = \Illuminate\Support\Facades\Redis::get($redis_key);
if (!$cache_data) {
// Cache doesn't exist - generate it
console_debug('SSR_FPC', "Cache miss for {$clean_url}, generating...");
\Illuminate\Support\Facades\Artisan::call('rsx:ssr_fpc:create', [
'url' => $clean_url
]);
// Read the newly generated cache
$cache_data = \Illuminate\Support\Facades\Redis::get($redis_key);
if (!$cache_data) {
throw new \RuntimeException("Failed to generate SSR FPC cache for route: {$clean_url}");
}
}
// Parse cache data
$cache = json_decode($cache_data, true);
if (!$cache) {
throw new \RuntimeException("Invalid SSR FPC cache data for route: {$clean_url}");
}
// Check ETag for 304 response
$client_etag = $request->header('If-None-Match');
if ($client_etag && $client_etag === $cache['etag']) {
return response('', 304)
->header('ETag', $cache['etag'])
->header('X-Cache', 'HIT')
->header('Cache-Control', app()->environment('production') ? 'public, max-age=300' : 'no-cache, must-revalidate');
}
// Determine cache control header
$cache_control = app()->environment('production')
? 'public, max-age=300' // 5 min in production
: 'no-cache, must-revalidate'; // 0s in dev
// Handle redirect response
if ($cache['code'] >= 300 && $cache['code'] < 400 && $cache['redirect']) {
return response('', $cache['code'])
->header('Location', $cache['redirect'])
->header('ETag', $cache['etag'])
->header('X-Cache', 'HIT')
->header('Cache-Control', $cache_control);
}
// Serve static HTML
console_debug('SSR_FPC', "Serving cached page for {$clean_url}");
return response($cache['page_dom'], $cache['code'])
->header('Content-Type', 'text/html; charset=UTF-8')
->header('ETag', $cache['etag'])
->header('X-Cache', 'HIT')
->header('X-FPC-Debug', 'served-from-cache')
->header('Cache-Control', $cache_control);
} catch (\Exception $e) {
// Log error and let request proceed normally
\Illuminate\Support\Facades\Log::error('SSR FPC error: ' . $e->getMessage());
throw new \RuntimeException('SSR FPC generation failed: ' . $e->getMessage(), 0, $e);
}
}
}