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>
463 lines
17 KiB
PHP
Executable File
463 lines
17 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\Ajax;
|
|
|
|
use Exception;
|
|
use Illuminate\Http\Request;
|
|
use App\RSpade\Core\Ajax\Exceptions\AjaxAuthRequiredException;
|
|
use App\RSpade\Core\Ajax\Exceptions\AjaxFatalErrorException;
|
|
use App\RSpade\Core\Ajax\Exceptions\AjaxFormErrorException;
|
|
use App\RSpade\Core\Ajax\Exceptions\AjaxUnauthorizedException;
|
|
use App\RSpade\Core\Manifest\Manifest;
|
|
use App\RSpade\Core\Response\Rsx_Response_Abstract;
|
|
|
|
// @FILE-SUBCLASS-01-EXCEPTION
|
|
|
|
/**
|
|
* Ajax - Unified AJAX handling system
|
|
*
|
|
* Consolidates all AJAX-related functionality:
|
|
* - Internal server-side API calls (internal method)
|
|
* - Browser AJAX request handling (handle_browser_request method)
|
|
* - Response mode management for error handlers
|
|
* - Future: External HTTP calls, WebSocket handling
|
|
*/
|
|
class Ajax
|
|
{
|
|
/**
|
|
* Flag to indicate AJAX response mode for error handlers
|
|
*/
|
|
protected static bool $ajax_response_mode = false;
|
|
|
|
/**
|
|
* Call an internal API method directly from PHP code
|
|
*
|
|
* Used for server-side code to invoke API methods without HTTP overhead.
|
|
* This is useful for internal service calls, background jobs, and testing.
|
|
*
|
|
* @param string $rsx_controller Controller name (e.g., 'User_Controller')
|
|
* @param string $rsx_action Action/method name (e.g., 'get_profile')
|
|
* @param array $params Parameters to pass to the method
|
|
* @param array $auth Authentication context (not yet implemented)
|
|
* @return mixed The filtered response from the API method
|
|
* @throws AjaxAuthRequiredException
|
|
* @throws AjaxUnauthorizedException
|
|
* @throws AjaxFormErrorException
|
|
* @throws AjaxFatalErrorException
|
|
* @throws Exception
|
|
*/
|
|
public static function internal($rsx_controller, $rsx_action, $params = [], $auth = [])
|
|
{
|
|
// Get manifest to find controller
|
|
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
|
|
$controller_class = null;
|
|
$file_info = null;
|
|
|
|
// Search for controller class in manifest
|
|
foreach ($manifest as $file_path => $info) {
|
|
// Skip non-PHP files or files without classes
|
|
if (!isset($info['class']) || !isset($info['fqcn'])) {
|
|
continue;
|
|
}
|
|
|
|
// Check if class name matches exactly (without namespace)
|
|
$class_basename = basename(str_replace('\\', '/', $info['fqcn']));
|
|
|
|
if ($class_basename === $rsx_controller) {
|
|
$controller_class = $info['fqcn'];
|
|
$file_info = $info;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$controller_class) {
|
|
throw new Exception("Controller class not found: {$rsx_controller}");
|
|
}
|
|
|
|
// Check if class exists
|
|
if (!class_exists($controller_class)) {
|
|
throw new Exception("Controller class does not exist: {$controller_class}");
|
|
}
|
|
|
|
// Check if it's a subclass of Rsx_Controller_Abstract
|
|
if (!Manifest::php_is_subclass_of($controller_class, \App\RSpade\Core\Controller\Rsx_Controller_Abstract::class)) {
|
|
throw new Exception("Controller {$controller_class} must extend Rsx_Controller_Abstract");
|
|
}
|
|
|
|
// Check if method exists and has Ajax_Endpoint attribute
|
|
if (!isset($file_info['public_static_methods'][$rsx_action])) {
|
|
throw new Exception("Method {$rsx_action} not found in controller {$controller_class}");
|
|
}
|
|
|
|
$method_info = $file_info['public_static_methods'][$rsx_action];
|
|
$has_ajax_endpoint = false;
|
|
|
|
// Check for Ajax_Endpoint attribute in method metadata
|
|
if (isset($method_info['attributes'])) {
|
|
foreach ($method_info['attributes'] as $attr_name => $attr_data) {
|
|
// Check for Ajax_Endpoint with or without namespace
|
|
if ($attr_name === 'Ajax_Endpoint' ||
|
|
basename(str_replace('\\', '/', $attr_name)) === 'Ajax_Endpoint') {
|
|
$has_ajax_endpoint = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$has_ajax_endpoint) {
|
|
throw new Exception("Method {$rsx_action} in {$controller_class} must have Ajax_Endpoint annotation");
|
|
}
|
|
|
|
// Create a request object with the params
|
|
$request = Request::create('/_ajax/' . $rsx_controller . '/' . $rsx_action, 'POST', $params);
|
|
$request->setMethod('POST');
|
|
|
|
// Call pre_dispatch if it exists
|
|
$response = null;
|
|
if (method_exists($controller_class, 'pre_dispatch')) {
|
|
$response = $controller_class::pre_dispatch($request, $params);
|
|
}
|
|
|
|
// If pre_dispatch returned something, use that as response
|
|
if ($response === null) {
|
|
// Call the actual method
|
|
$response = $controller_class::$rsx_action($request, $params);
|
|
}
|
|
|
|
// Handle special response types
|
|
if ($response instanceof Rsx_Response_Abstract) {
|
|
return static::_handle_special_response($response);
|
|
}
|
|
|
|
// For normal responses, filter through JSON encode/decode to remove PHP objects
|
|
// This ensures we only return plain data structures
|
|
$filtered_response = json_decode(json_encode($response), true);
|
|
|
|
return $filtered_response;
|
|
}
|
|
|
|
/**
|
|
* Handle special response types by throwing appropriate exceptions
|
|
*
|
|
* @param Rsx_Response_Abstract $response
|
|
* @throws AjaxAuthRequiredException
|
|
* @throws AjaxUnauthorizedException
|
|
* @throws AjaxFormErrorException
|
|
* @throws AjaxFatalErrorException
|
|
*/
|
|
protected static function _handle_special_response(Rsx_Response_Abstract $response)
|
|
{
|
|
$type = $response->get_type();
|
|
$reason = $response->get_reason();
|
|
$details = $response->get_details();
|
|
|
|
switch ($type) {
|
|
case 'response_auth_required':
|
|
throw new AjaxAuthRequiredException($reason);
|
|
case 'response_unauthorized':
|
|
throw new AjaxUnauthorizedException($reason);
|
|
case 'response_form_error':
|
|
throw new AjaxFormErrorException($reason, $details);
|
|
case 'fatal':
|
|
$message = $reason;
|
|
if (!empty($details)) {
|
|
$message .= ' - ' . json_encode($details);
|
|
}
|
|
|
|
throw new AjaxFatalErrorException($message);
|
|
default:
|
|
throw new Exception("Unknown RSX response type: {$type}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle incoming AJAX requests from the browser
|
|
*
|
|
* This method processes /_ajax/:controller/:action requests and returns JSON responses.
|
|
* It validates Ajax_Endpoint annotations and handles special response types.
|
|
*
|
|
* @param Request $request The incoming HTTP request
|
|
* @param array $params Route parameters including controller and action
|
|
* @return \Illuminate\Http\JsonResponse
|
|
* @throws Exception
|
|
*/
|
|
public static function handle_browser_request(Request $request, array $params = [])
|
|
{
|
|
// Enable AJAX response mode for error handlers
|
|
static::set_ajax_response_mode(true);
|
|
|
|
// Disable console debug HTML output for AJAX requests
|
|
\App\RSpade\Core\Debug\Debugger::disable_console_html_output();
|
|
|
|
// Get controller and action from params
|
|
$controller_name = $params['controller'] ?? null;
|
|
$action_name = $params['action'] ?? null;
|
|
|
|
if (!$controller_name || !$action_name) {
|
|
throw new Exception('Missing controller or action parameter');
|
|
}
|
|
|
|
// Use manifest to find the controller class
|
|
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
|
|
$controller_class = null;
|
|
$file_info = null;
|
|
|
|
// Search for controller class in manifest
|
|
foreach ($manifest as $file_path => $info) {
|
|
// Skip non-PHP files or files without classes
|
|
if (!isset($info['class']) || !isset($info['fqcn'])) {
|
|
continue;
|
|
}
|
|
|
|
// Check if class name matches exactly (without namespace)
|
|
$class_basename = basename(str_replace('\\', '/', $info['fqcn']));
|
|
|
|
if ($class_basename === $controller_name) {
|
|
$controller_class = $info['fqcn'];
|
|
$file_info = $info;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$controller_class) {
|
|
throw new Exception("Controller class not found: {$controller_name}");
|
|
}
|
|
|
|
// Check if class exists
|
|
if (!class_exists($controller_class)) {
|
|
throw new Exception("Controller class does not exist: {$controller_class}");
|
|
}
|
|
|
|
// Check if it's a subclass of Rsx_Controller_Abstract
|
|
if (!Manifest::php_is_subclass_of($controller_class, \App\RSpade\Core\Controller\Rsx_Controller_Abstract::class)) {
|
|
throw new Exception("Controller {$controller_class} must extend Rsx_Controller_Abstract");
|
|
}
|
|
|
|
// Check if method exists and has Ajax_Endpoint attribute
|
|
if (!isset($file_info['public_static_methods'][$action_name])) {
|
|
throw new Exception("Method {$action_name} not found in controller {$controller_class} public static methods");
|
|
}
|
|
|
|
$method_info = $file_info['public_static_methods'][$action_name];
|
|
$has_ajax_endpoint = false;
|
|
|
|
// Check for Ajax_Endpoint attribute in method metadata
|
|
if (isset($method_info['attributes'])) {
|
|
foreach ($method_info['attributes'] as $attr_name => $attr_data) {
|
|
// Check for Ajax_Endpoint with or without namespace
|
|
if ($attr_name === 'Ajax_Endpoint' ||
|
|
basename(str_replace('\\', '/', $attr_name)) === 'Ajax_Endpoint') {
|
|
$has_ajax_endpoint = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$has_ajax_endpoint) {
|
|
throw new Exception("Method {$action_name} in {$controller_class} must have Ajax_Endpoint annotation");
|
|
}
|
|
|
|
// Extract params from request body
|
|
$ajax_params = [];
|
|
|
|
// First check for JSON-encoded params field (old format)
|
|
$params_json = $request->input('params');
|
|
if ($params_json) {
|
|
$ajax_params = json_decode($params_json, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw new Exception('Invalid JSON in params: ' . json_last_error_msg());
|
|
}
|
|
} else {
|
|
// Get all request input (form-urlencoded or JSON body)
|
|
$all_input = $request->all();
|
|
|
|
// Remove route parameters (controller, action, etc)
|
|
$ajax_params = array_diff_key($all_input, array_flip(['controller', 'action', '_method', '_route', '_handler']));
|
|
}
|
|
|
|
// Call pre_dispatch if it exists
|
|
$response = null;
|
|
if (method_exists($controller_class, 'pre_dispatch')) {
|
|
$response = $controller_class::pre_dispatch($request, $ajax_params);
|
|
}
|
|
|
|
// If pre_dispatch returned something, use that as response
|
|
if ($response === null) {
|
|
// Call the actual method
|
|
$response = $controller_class::$action_name($request, $ajax_params);
|
|
}
|
|
|
|
// Handle special response types
|
|
if ($response instanceof \App\RSpade\Core\Response\Rsx_Response_Abstract) {
|
|
return static::_handle_browser_special_response($response);
|
|
}
|
|
|
|
// Wrap response using shared formatting method
|
|
$json_response = static::format_ajax_response($response);
|
|
|
|
return response()->json($json_response);
|
|
}
|
|
|
|
/**
|
|
* Handle special response types for browser AJAX requests
|
|
*
|
|
* @param \App\RSpade\Core\Response\Rsx_Response_Abstract $response
|
|
* @return \Illuminate\Http\JsonResponse
|
|
*/
|
|
protected static function _handle_browser_special_response(\App\RSpade\Core\Response\Rsx_Response_Abstract $response)
|
|
{
|
|
$type = $response->get_type();
|
|
|
|
// Handle fatal error - always throw exception
|
|
if ($type === 'fatal') {
|
|
$details = $response->get_details();
|
|
$message = $response->get_reason();
|
|
if (!empty($details)) {
|
|
$message .= ' - ' . json_encode($details);
|
|
}
|
|
|
|
throw new Exception($message);
|
|
}
|
|
|
|
// Build error response based on type
|
|
$json_response = [
|
|
'_success' => false,
|
|
'error_type' => $type,
|
|
'reason' => $response->get_reason(),
|
|
];
|
|
|
|
// Add details for form errors
|
|
if ($type === 'response_form_error') {
|
|
$json_response['details'] = $response->get_details();
|
|
}
|
|
|
|
// Add console debug messages if any
|
|
$console_messages = \App\RSpade\Core\Debug\Debugger::_get_console_messages();
|
|
if (!empty($console_messages)) {
|
|
// Messages are now structured as [channel, [arguments]]
|
|
$json_response['console_debug'] = $console_messages;
|
|
}
|
|
|
|
// Add flash_alerts if any pending for current session
|
|
$flash_messages = \App\RSpade\Lib\Flash\Flash_Alert::get_pending_messages();
|
|
if (!empty($flash_messages)) {
|
|
$json_response['flash_alerts'] = $flash_messages;
|
|
}
|
|
|
|
return response()->json($json_response);
|
|
}
|
|
|
|
/**
|
|
* Set AJAX response mode for error handlers
|
|
*
|
|
* When enabled, error handlers will return JSON instead of HTML
|
|
*
|
|
* @param bool $enabled
|
|
* @return void
|
|
*/
|
|
public static function set_ajax_response_mode(bool $enabled): void
|
|
{
|
|
static::$ajax_response_mode = $enabled;
|
|
}
|
|
|
|
/**
|
|
* Check if AJAX response mode is enabled
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function is_ajax_response_mode(): bool
|
|
{
|
|
return static::$ajax_response_mode;
|
|
}
|
|
|
|
/**
|
|
* Validate Ajax endpoint return value
|
|
*
|
|
* Ensures developers don't manually add 'success' field to their responses.
|
|
* The framework automatically wraps responses with success/failure metadata.
|
|
*
|
|
* @param mixed $response The Ajax method return value
|
|
* @throws Exception If response contains manual success field
|
|
*/
|
|
protected static function _validate_ajax_response($response): void
|
|
{
|
|
// Only validate if response is an array
|
|
if (!is_array($response)) {
|
|
return;
|
|
}
|
|
|
|
// Check if response contains '_success' or 'success' key with boolean value
|
|
if ((array_key_exists('_success', $response) && is_bool($response['_success'])) ||
|
|
(array_key_exists('success', $response) && is_bool($response['success']))) {
|
|
$wrong_way = "return ['_success' => false, 'message' => 'Error'];";
|
|
$right_way_validation = "return response_form_error('Validation failed', ['email' => 'Invalid']);";
|
|
$right_way_success = "return ['user_id' => 123, 'data' => [...]];\n// Framework wraps: {_success: true, _ajax_return_value: {...}}";
|
|
$right_way_exception = "// Let exceptions bubble - framework handles them\n\$user->save(); // Don't wrap in try/catch";
|
|
|
|
throw new Exception(
|
|
"YOU'RE DOING IT WRONG: Ajax endpoints must not return arrays with '_success' or 'success' boolean key.\n\n" .
|
|
"The '_success' field is reserved for framework-level Ajax response wrapping.\n\n" .
|
|
"WRONG WAY:\n" .
|
|
" {$wrong_way}\n\n" .
|
|
"RIGHT WAY (validation errors):\n" .
|
|
" {$right_way_validation}\n\n" .
|
|
"RIGHT WAY (success):\n" .
|
|
" {$right_way_success}\n\n" .
|
|
"RIGHT WAY (exceptions):\n" .
|
|
" {$right_way_exception}\n\n" .
|
|
"See: php artisan rsx:man ajax_error_handling"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format Ajax response with standard wrapper structure
|
|
*
|
|
* This method creates the standardized response format used by both
|
|
* HTTP Ajax endpoints and CLI Ajax commands. Ensures consistency across
|
|
* all Ajax response types.
|
|
*
|
|
* @param mixed $response The Ajax method return value
|
|
* @return array Formatted response array with success, _ajax_return_value, and optional console_debug
|
|
*/
|
|
public static function format_ajax_response($response): array
|
|
{
|
|
// Validate response before formatting
|
|
static::_validate_ajax_response($response);
|
|
|
|
$json_response = [
|
|
'_success' => true,
|
|
'_ajax_return_value' => $response,
|
|
];
|
|
|
|
// Add console debug messages if any
|
|
$console_messages = \App\RSpade\Core\Debug\Debugger::_get_console_messages();
|
|
if (!empty($console_messages)) {
|
|
// Messages are now structured as [channel, [arguments]]
|
|
$json_response['console_debug'] = $console_messages;
|
|
}
|
|
|
|
// Add flash_alerts if any pending for current session
|
|
$flash_messages = \App\RSpade\Lib\Flash\Flash_Alert::get_pending_messages();
|
|
if (!empty($flash_messages)) {
|
|
$json_response['flash_alerts'] = $flash_messages;
|
|
}
|
|
|
|
return $json_response;
|
|
}
|
|
|
|
/**
|
|
* Backward compatibility alias for internal()
|
|
* @deprecated Use internal() instead
|
|
*/
|
|
public static function call($rsx_controller, $rsx_action, $params = [], $auth = [])
|
|
{
|
|
return static::internal($rsx_controller, $rsx_action, $params, $auth);
|
|
}
|
|
}
|