Add 100+ automated unit tests from .expect file specifications Add session system test Add rsx:constants:regenerate command test Add rsx:logrotate command test Add rsx:clean command test Add rsx:manifest:stats command test Add model enum system test Add model mass assignment prevention test Add rsx:check command test Add migrate:status command test 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
469 lines
14 KiB
PHP
469 lines
14 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 Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Facades\View;
|
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
use App\RSpade\Core\Dispatch\HandlerFactory;
|
|
|
|
/**
|
|
* ResponseBuilder builds appropriate HTTP responses based on handler results
|
|
*
|
|
* Supports response types:
|
|
* - view: Render Laravel view
|
|
* - json: JSON response
|
|
* - redirect: HTTP redirect
|
|
* - file: File download/stream
|
|
* - stream: Streamed response
|
|
* - error: Error response
|
|
* - empty: Empty response
|
|
* - raw: Raw text/html response
|
|
*/
|
|
class ResponseBuilder
|
|
{
|
|
/**
|
|
* Response type constants
|
|
*/
|
|
const TYPE_VIEW = 'view';
|
|
const TYPE_JSON = 'json';
|
|
const TYPE_REDIRECT = 'redirect';
|
|
const TYPE_FILE = 'file';
|
|
const TYPE_STREAM = 'stream';
|
|
const TYPE_ERROR = 'error';
|
|
const TYPE_EMPTY = 'empty';
|
|
const TYPE_RAW = 'raw';
|
|
|
|
/**
|
|
* Default response headers by type
|
|
* @var array
|
|
*/
|
|
protected static $default_headers = [
|
|
self::TYPE_JSON => ['Content-Type' => 'application/json'],
|
|
self::TYPE_RAW => ['Content-Type' => 'text/html; charset=UTF-8'],
|
|
];
|
|
|
|
/**
|
|
* Build response from handler result
|
|
*
|
|
* @param mixed $result Handler result
|
|
* @param string $handler_type Handler type from HandlerFactory
|
|
* @param array $attributes Method attributes
|
|
* @return Response
|
|
*/
|
|
public static function build($result, $handler_type = null, $attributes = [])
|
|
{
|
|
// If already a Response object, return as-is
|
|
if ($result instanceof Response || $result instanceof JsonResponse ||
|
|
$result instanceof RedirectResponse || $result instanceof BinaryFileResponse ||
|
|
$result instanceof StreamedResponse) {
|
|
return $result;
|
|
}
|
|
|
|
// Handle array responses with type hints
|
|
if (is_array($result) && isset($result['type'])) {
|
|
return static::__build_typed_response($result);
|
|
}
|
|
|
|
// Handle based on handler type
|
|
if ($handler_type !== null) {
|
|
return static::__build_by_handler_type($result, $handler_type, $attributes);
|
|
}
|
|
|
|
// Default: return as JSON
|
|
return static::__build_json_response($result);
|
|
}
|
|
|
|
/**
|
|
* Build response from typed result array
|
|
*
|
|
* @param array $result
|
|
* @return Response
|
|
*/
|
|
protected static function __build_typed_response($result)
|
|
{
|
|
$type = $result['type'];
|
|
|
|
switch ($type) {
|
|
case self::TYPE_VIEW:
|
|
return static::__build_view_response($result);
|
|
|
|
case self::TYPE_JSON:
|
|
return static::__build_json_response(
|
|
$result['data'] ?? $result,
|
|
$result['status'] ?? 200,
|
|
$result['headers'] ?? []
|
|
);
|
|
|
|
case self::TYPE_REDIRECT:
|
|
return static::__build_redirect_response($result);
|
|
|
|
case self::TYPE_FILE:
|
|
return static::__build_file_response($result);
|
|
|
|
case self::TYPE_STREAM:
|
|
return static::__build_stream_response($result);
|
|
|
|
case self::TYPE_ERROR:
|
|
return static::__build_error_response($result);
|
|
|
|
case self::TYPE_EMPTY:
|
|
return static::__build_empty_response(
|
|
$result['status'] ?? 204,
|
|
$result['headers'] ?? []
|
|
);
|
|
|
|
case self::TYPE_RAW:
|
|
return static::__build_raw_response($result);
|
|
|
|
default:
|
|
// Unknown type, return as JSON
|
|
return static::__build_json_response($result);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build response based on handler type
|
|
*
|
|
* @param mixed $result
|
|
* @param string $handler_type
|
|
* @param array $attributes
|
|
* @return Response
|
|
*/
|
|
protected static function __build_by_handler_type($result, $handler_type, $attributes)
|
|
{
|
|
switch ($handler_type) {
|
|
case HandlerFactory::TYPE_CONTROLLER:
|
|
// Controllers default to view responses
|
|
if (is_array($result) && !isset($result['type'])) {
|
|
$result['type'] = self::TYPE_VIEW;
|
|
return static::__build_typed_response($result);
|
|
}
|
|
break;
|
|
|
|
case HandlerFactory::TYPE_API:
|
|
// API handlers default to JSON
|
|
if (!is_array($result) || !isset($result['type'])) {
|
|
return static::__build_json_response($result);
|
|
}
|
|
break;
|
|
|
|
case HandlerFactory::TYPE_FILE:
|
|
// File handlers default to file responses
|
|
if (is_array($result) && !isset($result['type'])) {
|
|
$result['type'] = self::TYPE_FILE;
|
|
return static::__build_typed_response($result);
|
|
}
|
|
break;
|
|
}
|
|
|
|
// If we have a typed response, build it
|
|
if (is_array($result) && isset($result['type'])) {
|
|
return static::__build_typed_response($result);
|
|
}
|
|
|
|
// Default to JSON
|
|
return static::__build_json_response($result);
|
|
}
|
|
|
|
/**
|
|
* Build view response
|
|
*
|
|
* @param array $result
|
|
* @return Response
|
|
*/
|
|
protected static function __build_view_response($result)
|
|
{
|
|
$view = $result['view'] ?? 'welcome';
|
|
$data = $result['data'] ?? [];
|
|
$status = $result['status'] ?? 200;
|
|
$headers = $result['headers'] ?? [];
|
|
|
|
// Check if view exists
|
|
if (!View::exists($view)) {
|
|
// Return error response if view not found
|
|
return static::__build_error_response([
|
|
'code' => 500,
|
|
'message' => "View not found: {$view}"
|
|
]);
|
|
}
|
|
|
|
return response()->view($view, $data, $status)->withHeaders($headers);
|
|
}
|
|
|
|
/**
|
|
* Build JSON response
|
|
*
|
|
* @param mixed $data
|
|
* @param int $status
|
|
* @param array $headers
|
|
* @return JsonResponse
|
|
*/
|
|
protected static function __build_json_response($data, $status = 200, $headers = [])
|
|
{
|
|
// Merge with default JSON headers
|
|
$headers = array_merge(static::$default_headers[self::TYPE_JSON], $headers);
|
|
|
|
return response()->json($data, $status, $headers);
|
|
}
|
|
|
|
/**
|
|
* Build redirect response
|
|
*
|
|
* @param array $result
|
|
* @return Response
|
|
*/
|
|
protected static function __build_redirect_response($result)
|
|
{
|
|
$url = $result['url'] ?? '/';
|
|
$status = $result['status'] ?? 302;
|
|
$headers = $result['headers'] ?? [];
|
|
$secure = $result['secure'] ?? null;
|
|
|
|
// Use Laravel's redirect helper for proper URL generation
|
|
$response = redirect($url, $status, $headers, $secure);
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Build file response
|
|
*
|
|
* @param array $result
|
|
* @return BinaryFileResponse|Response
|
|
*/
|
|
protected static function __build_file_response($result)
|
|
{
|
|
$path = $result['path'] ?? null;
|
|
|
|
if (!$path || !file_exists($path)) {
|
|
return static::__build_error_response([
|
|
'code' => 404,
|
|
'message' => 'File not found'
|
|
]);
|
|
}
|
|
|
|
$headers = $result['headers'] ?? [];
|
|
|
|
// Create file response
|
|
$response = response()->file($path, $headers);
|
|
|
|
// Set download name if provided
|
|
if (isset($result['name'])) {
|
|
$disposition = $result['disposition'] ?? 'attachment';
|
|
$response->headers->set(
|
|
'Content-Disposition',
|
|
"{$disposition}; filename=\"" . $result['name'] . "\""
|
|
);
|
|
}
|
|
|
|
// Set MIME type if provided
|
|
if (isset($result['mime'])) {
|
|
$response->headers->set('Content-Type', $result['mime']);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Build stream response
|
|
*
|
|
* @param array $result
|
|
* @return StreamedResponse
|
|
*/
|
|
protected static function __build_stream_response($result)
|
|
{
|
|
$callback = $result['callback'] ?? function() { echo ''; };
|
|
$status = $result['status'] ?? 200;
|
|
$headers = $result['headers'] ?? [];
|
|
|
|
return response()->stream($callback, $status, $headers);
|
|
}
|
|
|
|
/**
|
|
* Build error response
|
|
*
|
|
* @param array $result
|
|
* @return Response
|
|
*/
|
|
protected static function __build_error_response($result)
|
|
{
|
|
$code = $result['code'] ?? 500;
|
|
$message = $result['message'] ?? 'Server Error';
|
|
$headers = $result['headers'] ?? [];
|
|
|
|
// Use Laravel's abort for proper error handling
|
|
abort($code, $message);
|
|
}
|
|
|
|
/**
|
|
* Build empty response
|
|
*
|
|
* @param int $status
|
|
* @param array $headers
|
|
* @return Response
|
|
*/
|
|
protected static function __build_empty_response($status = 204, $headers = [])
|
|
{
|
|
return response('', $status, $headers);
|
|
}
|
|
|
|
/**
|
|
* Build raw response
|
|
*
|
|
* @param array $result
|
|
* @return Response
|
|
*/
|
|
protected static function __build_raw_response($result)
|
|
{
|
|
$content = $result['content'] ?? '';
|
|
$status = $result['status'] ?? 200;
|
|
$headers = array_merge(
|
|
static::$default_headers[self::TYPE_RAW],
|
|
$result['headers'] ?? []
|
|
);
|
|
|
|
return response($content, $status, $headers);
|
|
}
|
|
|
|
/**
|
|
* Set CORS headers for response
|
|
*
|
|
* @param Response $response
|
|
* @param array $cors_config
|
|
* @return Response
|
|
*/
|
|
public static function set_cors_headers($response, $cors_config = [])
|
|
{
|
|
$default_cors = [
|
|
'Access-Control-Allow-Origin' => '*',
|
|
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
|
|
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-Requested-With',
|
|
'Access-Control-Max-Age' => '86400',
|
|
];
|
|
|
|
$cors = array_merge($default_cors, $cors_config);
|
|
|
|
foreach ($cors as $header => $value) {
|
|
$response->headers->set($header, $value);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Set cache headers for response
|
|
*
|
|
* @param Response $response
|
|
* @param int $ttl Time to live in seconds
|
|
* @param array $options Additional cache options
|
|
* @return Response
|
|
*/
|
|
public static function set_cache_headers($response, $ttl = 3600, $options = [])
|
|
{
|
|
if ($ttl > 0) {
|
|
$response->headers->set('Cache-Control', "public, max-age={$ttl}");
|
|
|
|
// Set expires header
|
|
$expires = gmdate('D, d M Y H:i:s', time() + $ttl) . ' GMT';
|
|
$response->headers->set('Expires', $expires);
|
|
|
|
// Set ETag if provided
|
|
if (isset($options['etag'])) {
|
|
$response->headers->set('ETag', $options['etag']);
|
|
}
|
|
|
|
// Set Last-Modified if provided
|
|
if (isset($options['last_modified'])) {
|
|
$response->headers->set('Last-Modified', $options['last_modified']);
|
|
}
|
|
} else {
|
|
// No cache
|
|
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
$response->headers->set('Pragma', 'no-cache');
|
|
$response->headers->set('Expires', '0');
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Apply response attributes
|
|
*
|
|
* @param Response $response
|
|
* @param array $attributes
|
|
* @return Response
|
|
*/
|
|
public static function apply_attributes($response, $attributes)
|
|
{
|
|
// Apply CORS if specified
|
|
if (isset($attributes['cors'])) {
|
|
static::set_cors_headers($response, $attributes['cors']);
|
|
}
|
|
|
|
// Apply cache if specified
|
|
if (isset($attributes['cache'])) {
|
|
$cache_config = $attributes['cache'];
|
|
$ttl = $cache_config['ttl'] ?? 3600;
|
|
static::set_cache_headers($response, $ttl, $cache_config);
|
|
}
|
|
|
|
// Apply custom headers
|
|
if (isset($attributes['headers'])) {
|
|
foreach ($attributes['headers'] as $header => $value) {
|
|
$response->headers->set($header, $value);
|
|
}
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Build response for asset file
|
|
*
|
|
* @param string $path
|
|
* @param string $base_path
|
|
* @return Response
|
|
*/
|
|
public static function build_asset_response($path, $base_path = null)
|
|
{
|
|
// Resolve full path
|
|
if ($base_path) {
|
|
$full_path = rtrim($base_path, '/') . '/' . ltrim($path, '/');
|
|
} else {
|
|
$full_path = public_path($path);
|
|
}
|
|
|
|
if (!file_exists($full_path)) {
|
|
return static::__build_error_response([
|
|
'code' => 404,
|
|
'message' => 'Asset not found'
|
|
]);
|
|
}
|
|
|
|
// Determine MIME type
|
|
$mime = mime_content_type($full_path);
|
|
|
|
// Build response
|
|
$response = response()->file($full_path, [
|
|
'Content-Type' => $mime
|
|
]);
|
|
|
|
// Set cache headers for assets (1 year for production)
|
|
if (app()->environment('production')) {
|
|
static::set_cache_headers($response, 31536000); // 1 year
|
|
} else {
|
|
static::set_cache_headers($response, 3600); // 1 hour for dev
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
} |