Files
rspade_system/app/RSpade/Core/Ajax/Ajax.php
root 1b57ec2785 Add datetime system (Rsx_Time/Rsx_Date) and .expect file documentation system
Tighten CLAUDE.dist.md for LLM audience - 15% size reduction
Add Repeater_Simple_Input component for managing lists of simple values
Add Polymorphic_Field_Helper for JSON-encoded polymorphic form fields
Fix incorrect data-sid selector in route-debug help example
Fix Form_Utils to use component.$sid() instead of data-sid selector
Add response helper functions and use _message as reserved metadata key

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-24 21:47:53 +00:00

501 lines
19 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
{
// Error code constants
const ERROR_VALIDATION = 'validation';
const ERROR_NOT_FOUND = 'not_found';
const ERROR_UNAUTHORIZED = 'unauthorized';
const ERROR_AUTH_REQUIRED = 'auth_required';
const ERROR_FATAL = 'fatal';
const ERROR_GENERIC = 'generic';
const ERROR_SERVER = 'server_error'; // Client-generated (HTTP 500)
const ERROR_NETWORK = 'network_error'; // Client-generated (connection failed)
/**
* Flag to indicate AJAX response mode for error handlers
*/
protected static bool $ajax_response_mode = false;
/**
* Get default message for error code
*
* @param string $error_code One of the ERROR_* constants
* @return string Default user-friendly message
*/
public static function get_default_message(string $error_code): string
{
return match ($error_code) {
self::ERROR_VALIDATION => 'Please correct the errors below',
self::ERROR_NOT_FOUND => 'The requested record was not found',
self::ERROR_UNAUTHORIZED => 'You do not have permission to perform this action',
self::ERROR_AUTH_REQUIRED => 'Please log in to continue',
self::ERROR_FATAL => 'A fatal error has occurred',
self::ERROR_SERVER => 'A server error occurred. Please try again.',
self::ERROR_NETWORK => 'Could not connect to server. Please check your connection.',
self::ERROR_GENERIC => 'An error has occurred',
default => 'An error has occurred',
};
}
/**
* 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 self::ERROR_AUTH_REQUIRED:
throw new AjaxAuthRequiredException($reason);
case self::ERROR_UNAUTHORIZED:
throw new AjaxUnauthorizedException($reason);
case self::ERROR_VALIDATION:
case self::ERROR_NOT_FOUND:
throw new AjaxFormErrorException($reason, $details);
case self::ERROR_FATAL:
$message = $reason;
if (!empty($details)) {
$message .= ' - ' . json_encode($details);
}
throw new AjaxFatalErrorException($message);
case self::ERROR_GENERIC:
throw new Exception($reason);
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
$json_response = [
'_success' => false,
'error_code' => $response->get_error_code(),
'reason' => $response->get_reason(),
'metadata' => $response->get_metadata(),
'_server_time' => \App\RSpade\Core\Time\Rsx_Time::now_iso(),
'_user_timezone' => \App\RSpade\Core\Time\Rsx_Time::get_user_timezone(),
];
// 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_error(Ajax::ERROR_VALIDATION, ['email' => 'Invalid email']);";
$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,
'_server_time' => \App\RSpade\Core\Time\Rsx_Time::now_iso(),
'_user_timezone' => \App\RSpade\Core\Time\Rsx_Time::get_user_timezone(),
];
// 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);
}
}