Files
rspade_system/app/RSpade/Core/Dispatch/Dispatcher.php
root 84ca3dfe42 Fix code quality violations and rename select input components
Move small tasks from wishlist to todo, update npm packages
Replace #[Auth] attributes with manual auth checks and code quality rule
Remove on_jqhtml_ready lifecycle method from framework
Complete ACL system with 100-based role indexing and /dev/acl tester
WIP: ACL system implementation with debug instrumentation
Convert rsx:check JS linting to RPC socket server
Clean up docs and fix $id→$sid in man pages, remove SSR/FPC feature
Reorganize wishlists: priority order, mark sublayouts complete, add email
Update model_fetch docs: mark MVP complete, fix enum docs, reorganize
Comprehensive documentation overhaul: clarity, compression, and critical rules
Convert Contacts/Projects CRUD to Model.fetch() and add fetch_or_null()
Add JS ORM relationship lazy-loading and fetch array handling
Add JS ORM relationship fetching and CRUD documentation
Fix ORM hydration and add IDE resolution for Base_* model stubs
Rename Json_Tree_Component to JS_Tree_Debug_Component and move to framework
Enhance JS ORM infrastructure and add Json_Tree class name badges

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:39:43 +00:00

998 lines
37 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'] ?? [];
// 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("<?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);
}
}
}