🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
999 lines
38 KiB
PHP
999 lines
38 KiB
PHP
<?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 (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("<?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);
|
|
}
|
|
}
|
|
}
|