Files
michaelschiemer/src/Framework/LiveComponents/LiveComponentHandler.php
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

736 lines
28 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Framework\LiveComponents;
use App\Framework\Core\Events\EventDispatcherInterface;
use App\Framework\Http\Session\SessionInterface;
use App\Framework\Http\UploadedFile;
use App\Framework\Idempotency\IdempotencyService;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Attributes\RequiresPermission;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Contracts\SupportsFileUpload;
use App\Framework\LiveComponents\Events\ComponentUpdatedEvent;
use App\Framework\LiveComponents\Exceptions\RateLimitExceededException;
use App\Framework\LiveComponents\Exceptions\StateValidationException;
use App\Framework\LiveComponents\Exceptions\UnauthorizedActionException;
use App\Framework\LiveComponents\ParameterBinding\ParameterBinder;
use App\Framework\LiveComponents\Security\ActionAuthorizationChecker;
use App\Framework\LiveComponents\Services\LiveComponentRateLimiter;
use App\Framework\LiveComponents\Validation\DefaultStateValidator;
use App\Framework\LiveComponents\Validation\DerivedSchema;
use App\Framework\LiveComponents\Validation\SchemaCache;
use App\Framework\LiveComponents\Validation\StateValidator;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
use App\Framework\LiveComponents\ValueObjects\LiveComponentState;
use App\Framework\LiveComponents\ValueObjects\ReservedActionName;
use App\Framework\Performance\NestedPerformanceTracker;
use App\Framework\Performance\PerformanceCategory;
/**
* Handles LiveComponent action execution and state updates
*
* Composition over inheritance - this replaces the LiveComponentTrait.
* Components no longer need to implement handle() logic themselves.
*
* The handler manages:
* - Event dispatching
* - CSRF protection
* - Action allow-list validation (#[Action] attribute)
* - Authorization checks via #[RequiresPermission] attribute
* - Rate limiting per component/action
* - Idempotency protection for duplicate requests
* - State validation with automatic schema derivation
*/
final readonly class LiveComponentHandler
{
private StateValidator $stateValidator;
public function __construct(
private ComponentEventDispatcher $eventDispatcher,
private SessionInterface $session,
private ActionAuthorizationChecker $authorizationChecker,
private SchemaCache $schemaCache,
private LiveComponentRateLimiter $rateLimiter,
private IdempotencyService $idempotency,
private ParameterBinder $parameterBinder,
private EventDispatcherInterface $frameworkEventDispatcher,
private NestedPerformanceTracker $performanceTracker
) {
$this->stateValidator = new DefaultStateValidator();
}
/**
* Handle component action
*
* @param LiveComponentContract $component The component instance
* @param string $method The action method to call
* @param ActionParameters $params Parameters for the action
* @return ComponentUpdate Update with new state and events (HTML will be filled by controller)
* @throws \RuntimeException if CSRF validation fails
* @throws \BadMethodCallException if method doesn't exist
* @throws UnauthorizedActionException if authorization check fails
* @throws StateValidationException if state validation fails
* @throws RateLimitExceededException if rate limit is exceeded
*/
public function handle(
LiveComponentContract $component,
string $method,
ActionParameters $params
): ComponentUpdate {
// 1. CSRF Protection - validate token before executing action
// Skip CSRF for 'poll' action as it's framework-initiated, not user-initiated
if ($method !== 'poll') {
$this->validateCsrf($component->id, $params);
}
// 2. Validate action is allowed (not reserved and has #[Action] attribute)
$actionAttribute = $this->validateAction($component, $method);
// 3. Idempotency Protection - check if this request has already been processed
if ($params->hasIdempotencyKey() && $actionAttribute?->idempotencyTTL !== null) {
return $this->handleWithIdempotency(
$component,
$method,
$params,
$actionAttribute
);
}
// 4. Rate Limiting - check if client has exceeded rate limits
$this->validateRateLimit($component, $method, $params, $actionAttribute);
// 5. Authorization Check - verify user has required permissions
$this->validateAuthorization($component, $method);
// 6. Execute action without idempotency
return $this->executeActionAndBuildUpdate($component, $method, $params);
}
/**
* Handle action with idempotency protection
*
* Uses IdempotencyService to cache results and prevent duplicate execution.
* If the idempotency key has been seen before, returns cached result.
*
* @param LiveComponentContract $component Component instance
* @param string $method Action method name
* @param ActionParameters $params Action parameters with idempotency key
* @param Action $actionAttribute Action attribute with TTL configuration
* @return ComponentUpdate Cached or fresh component update
*/
private function handleWithIdempotency(
LiveComponentContract $component,
string $method,
ActionParameters $params,
Action $actionAttribute
): ComponentUpdate {
$idempotencyKey = $params->getIdempotencyKey();
// Use IdempotencyService to execute with caching
return $this->idempotency->execute(
key: $idempotencyKey,
operation: function () use ($component, $method, $params, $actionAttribute): ComponentUpdate {
// Rate Limiting - check if client has exceeded rate limits
$this->validateRateLimit($component, $method, $params, $actionAttribute);
// Authorization Check - verify user has required permissions
$this->validateAuthorization($component, $method);
// Execute action and build update
return $this->executeActionAndBuildUpdate($component, $method, $params);
},
ttl: \App\Framework\Core\ValueObjects\Duration::fromSeconds($actionAttribute->idempotencyTTL)
);
}
/**
* Execute action and build component update
*
* Core action execution logic extracted for reuse in both
* idempotent and non-idempotent flows.
*
* @param LiveComponentContract $component Component instance
* @param string $method Action method name
* @param ActionParameters $params Action parameters
* @return ComponentUpdate Component update with new state and events
*/
private function executeActionAndBuildUpdate(
LiveComponentContract $component,
string $method,
ActionParameters $params
): ComponentUpdate {
return $this->performanceTracker->measure(
"livecomponent.{$component->id->name}.{$method}",
PerformanceCategory::CUSTOM,
function () use ($component, $method, $params): ComponentUpdate {
// 1. Derive or get cached schema
$schema = $this->performanceTracker->measure(
"livecomponent.schema.derive",
PerformanceCategory::CACHE,
fn() => $this->getOrDeriveSchema($component),
['component' => $component->id->name]
);
// 2. Clear any previous events
$this->eventDispatcher->clear();
// 3. Call action method with params and event dispatcher
// Components can optionally accept ComponentEventDispatcher as last parameter
$newData = $this->performanceTracker->measure(
"livecomponent.action.execute",
PerformanceCategory::CUSTOM,
fn() => $this->executeAction($component, $method, $params),
['action' => $method, 'component' => $component->id->name]
);
// 4. Extract component name from ID
$componentId = $component->id;
$componentName = $componentId->name;
// 5. Get dispatched events
$events = $this->eventDispatcher->getEvents();
// 6. Action methods now return State VOs directly (e.g., CounterState, SearchState)
// If action didn't return anything, get current state
$stateObject = $newData ?? $component->state;
// 7. Validate state against derived schema
$this->performanceTracker->measure(
"livecomponent.state.validate",
PerformanceCategory::CUSTOM,
fn() => $this->stateValidator->validateState($stateObject, $schema),
['component' => $component->id->name]
);
// 8. Call onUpdate() lifecycle hook if component implements LifecycleAware
$this->performanceTracker->measure(
"livecomponent.lifecycle.onUpdate",
PerformanceCategory::CUSTOM,
fn() => $this->callUpdateHook($component, $stateObject),
['component' => $component->id->name]
);
// 9. Convert State VO to array for serialization
$stateArray = $stateObject->toArray();
// 10. Build ComponentUpdate
// The LiveComponentController will render HTML using ComponentRegistry
$componentUpdate = new ComponentUpdate(
html: '', // Will be populated by controller
events: $events,
state: new LiveComponentState(
id: $componentId->toString(),
component: $componentName,
data: $stateArray
)
);
// 11. Dispatch domain event for SSE broadcasting
// This enables real-time updates via Server-Sent Events
$this->frameworkEventDispatcher->dispatch(
new ComponentUpdatedEvent(
componentId: $componentId,
state: $stateArray,
html: null, // HTML will be rendered by controller
events: $events
)
);
return $componentUpdate;
},
['component' => $component->id->name, 'action' => $method]
);
}
/**
* Execute action method with advanced parameter binding
*
* Uses ParameterBinder for:
* - Builtin type casting
* - DTO instantiation via constructor promotion
* - Framework service injection
* - Multiple naming conventions
*/
private function executeAction(
LiveComponentContract $component,
string $method,
ActionParameters $params
): mixed {
// Try reflection first for real methods with parameter analysis
try {
$reflection = new \ReflectionMethod($component, $method);
} catch (\ReflectionException $e) {
// Magic method via __call() - call directly with params array
// The component's __call() implementation handles parameter mapping
return $component->$method(...$params->toArray());
}
// Use ParameterBinder for advanced parameter binding
$args = $this->parameterBinder->bindParameters($reflection, $params);
// Call method with bound arguments
return $component->$method(...$args);
}
/**
* Handle file upload action
*
* Similar to handle() but specifically for file uploads.
* Calls handleUpload() on components that implement SupportsFileUpload.
*
* @param SupportsFileUpload $component The component that supports uploads
* @param UploadedFile $file The uploaded file
* @param ActionParameters $params Additional upload parameters
* @return ComponentUpdate Update with new state and events
* @throws \RuntimeException if CSRF validation fails
* @throws UnauthorizedActionException if authorization check fails
* @throws StateValidationException if state validation fails
*/
public function handleUpload(
SupportsFileUpload $component,
UploadedFile $file,
ActionParameters $params
): ComponentUpdate {
// 1. CSRF Protection - validate token before executing upload
$this->validateCsrf($component->id, $params);
// 2. Authorization Check - verify user has required permissions for upload
$this->validateAuthorization($component, 'handleUpload');
// 3. Derive or get cached schema
$schema = $this->getOrDeriveSchema($component);
// 4. Clear any previous events
$this->eventDispatcher->clear();
// Call handleUpload with file, params, and event dispatcher
$newData = $component->handleUpload($file, $params, $this->eventDispatcher);
// Extract component name from ID
$componentId = $component->id;
$componentName = $componentId->name;
// Get dispatched events
$events = $this->eventDispatcher->getEvents();
// handleUpload() now returns State VO directly
$stateObject = $newData ?? $component->state;
// 5. Validate state against derived schema
$this->stateValidator->validateState($stateObject, $schema);
// 6. Call onUpdate() lifecycle hook if component implements LifecycleAware
$this->callUpdateHook($component, $stateObject);
// 7. Convert State VO to array
$stateArray = $stateObject->toArray();
// 8. Build ComponentUpdate
$componentUpdate = new ComponentUpdate(
html: '', // Will be populated by controller
events: $events,
state: new LiveComponentState(
id: $componentId->toString(),
component: $componentName,
data: $stateArray
)
);
// 9. Dispatch domain event for SSE broadcasting
$this->frameworkEventDispatcher->dispatch(
new ComponentUpdatedEvent(
componentId: $componentId,
state: $stateArray,
html: null, // HTML will be rendered by controller
events: $events
)
);
return $componentUpdate;
}
/**
* Validate CSRF token for component action
*
* Uses component ID as form ID for CSRF validation.
* This ensures each component instance has its own CSRF protection.
*
* @param ComponentId $componentId Component identifier
* @param ActionParameters $params Action parameters containing CSRF token
* @throws \RuntimeException if CSRF validation fails
* @throws \InvalidArgumentException if CSRF token is missing or invalid
*/
private function validateCsrf(ComponentId $componentId, ActionParameters $params): void
{
// Check if CSRF token is present
if (! $params->hasCsrfToken()) {
throw new \InvalidArgumentException(
'CSRF token is required for LiveComponent actions'
);
}
$csrfToken = $params->getCsrfToken();
// Use component ID as form ID for CSRF validation
// This provides per-component-instance CSRF protection
$formId = 'livecomponent:' . $componentId->toString();
// Validate token using session's CSRF protection
if (! $this->session->csrf->validateToken($formId, $csrfToken)) {
throw new \RuntimeException(
'CSRF token validation failed for component: ' . $componentId->toString()
);
}
}
/**
* Validate that an action is allowed to be called
*
* Checks:
* 1. Method is not reserved (framework methods, lifecycle hooks, magic methods)
* 2. Method exists on component
* 3. Method has #[Action] attribute (allow-list)
* 4. Method is public and non-static
*
* @return Action|null The Action attribute instance (for rate limit configuration)
* @throws \BadMethodCallException if action is invalid
*/
private function validateAction(LiveComponentContract $component, string $method): ?Action
{
// 1. Check if method is reserved
if (ReservedActionName::isReserved($method)) {
$reserved = ReservedActionName::tryFrom($method);
$reason = $reserved?->getReasonMessage() ?? 'Reserved method';
throw new \BadMethodCallException(
"Cannot call reserved method '{$method}' as action on " . get_class($component) . ". " .
"Reason: {$reason}. " .
"Available actions: " . implode(', ', $this->getAvailableActions($component))
);
}
// 2. Check if method exists
if (! method_exists($component, $method)) {
// Find similar action names for better error messages
$availableActions = $this->getAvailableActions($component);
$suggestions = $this->findSimilarActions($method, $availableActions);
$errorMessage = "Action '{$method}' not found on " . get_class($component) . ".";
if (! empty($suggestions)) {
$errorMessage .= " Did you mean: " . implode(', ', $suggestions) . "?";
}
if (! empty($availableActions)) {
$errorMessage .= " Available actions: " . implode(', ', $availableActions);
} else {
$errorMessage .= " Component has no available actions (no methods with #[Action] attribute).";
}
throw new \BadMethodCallException($errorMessage);
}
// 3. Check if method has #[Action] attribute
$reflection = new \ReflectionMethod($component, $method);
$actionAttributes = $reflection->getAttributes(Action::class);
if (empty($actionAttributes)) {
$availableActions = $this->getAvailableActions($component);
throw new \BadMethodCallException(
"Method '{$method}' on " . get_class($component) . " is not marked as an action. " .
"Add #[Action] attribute to make it callable from client. " .
"Available actions: " . implode(', ', $availableActions)
);
}
// 4. Check if method is public and non-static
if (! $reflection->isPublic()) {
throw new \BadMethodCallException(
"Action '{$method}' on " . get_class($component) . " must be public"
);
}
if ($reflection->isStatic()) {
throw new \BadMethodCallException(
"Action '{$method}' on " . get_class($component) . " cannot be static"
);
}
// 5. Validate return type is an object (State VO)
$returnType = $reflection->getReturnType();
if ($returnType instanceof \ReflectionNamedType) {
$typeName = $returnType->getName();
// Must return an object type (not primitive, not array)
if (in_array($typeName, ['void', 'null', 'int', 'float', 'string', 'bool', 'array'], true)) {
throw new \BadMethodCallException(
"Action '{$method}' on " . get_class($component) . " must return a State object (e.g., CounterState, SearchState), not {$typeName}"
);
}
}
// Return the Action attribute instance for rate limit configuration
return $actionAttributes[0]->newInstance();
}
/**
* Get list of available actions (methods with #[Action] attribute)
*
* @return array<string>
*/
private function getAvailableActions(LiveComponentContract $component): array
{
$reflection = new \ReflectionClass($component);
$actions = [];
foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
// Skip static methods
if ($method->isStatic()) {
continue;
}
// Skip reserved methods
if (ReservedActionName::isReserved($method->getName())) {
continue;
}
// Check for #[Action] attribute
$actionAttributes = $method->getAttributes(Action::class);
if (! empty($actionAttributes)) {
$actions[] = $method->getName();
}
}
return $actions;
}
/**
* Find similar action names using Levenshtein distance
*
* @param string $searchAction Action user tried to call
* @param array<string> $availableActions Available actions on component
* @return array<string> Similar action names (distance <= 3)
*/
private function findSimilarActions(string $searchAction, array $availableActions): array
{
$suggestions = [];
foreach ($availableActions as $actionName) {
$distance = levenshtein(
strtolower($searchAction),
strtolower($actionName)
);
// Consider it a suggestion if distance is small
if ($distance <= 3) {
$suggestions[] = $actionName;
}
}
return $suggestions;
}
/**
* Validate authorization for component action
*
* Checks if user has required permissions via #[RequiresPermission] attribute.
* If no attribute is present, authorization is not required.
*
* @param LiveComponentContract $component Component instance
* @param string $method Action method name
* @throws UnauthorizedActionException if authorization check fails
*/
private function validateAuthorization(LiveComponentContract $component, string $method): void
{
// Get RequiresPermission attribute from method
$permissionAttribute = $this->getRequiresPermissionAttribute($component, $method);
// No permission requirement → allow access
if ($permissionAttribute === null) {
return;
}
// Check authorization
$isAuthorized = $this->authorizationChecker->isAuthorized(
$component,
$method,
$permissionAttribute
);
if (! $isAuthorized) {
$componentName = $component->id->name;
$userPermissions = $this->authorizationChecker->getUserPermissions();
// Check if it's an authentication issue (user not logged in)
if (! $this->authorizationChecker->isAuthenticated()) {
throw UnauthorizedActionException::forUnauthenticatedUser(
$componentName,
$method
);
}
// It's an authorization issue (user lacks permission)
throw UnauthorizedActionException::forMissingPermission(
$componentName,
$method,
$permissionAttribute,
$userPermissions
);
}
}
/**
* Validate rate limiting for component action
*
* Checks if client has exceeded rate limits for this action.
* Uses #[Action] attribute configuration or falls back to defaults.
*
* @param LiveComponentContract $component Component instance
* @param string $method Action method name
* @param ActionParameters $params Action parameters (contains client identifier)
* @param Action|null $actionAttribute Action attribute for rate limit configuration
* @throws RateLimitExceededException if rate limit is exceeded
*/
private function validateRateLimit(
LiveComponentContract $component,
string $method,
ActionParameters $params,
?Action $actionAttribute
): void {
// Skip if no client identifier
if (! $params->hasClientIdentifier()) {
return;
}
$clientIdentifier = $params->getClientIdentifier();
// Check rate limit
$result = $this->rateLimiter->checkActionLimit(
$component,
$method,
$clientIdentifier,
$actionAttribute
);
// Throw exception if exceeded
if ($result->isExceeded()) {
$componentName = $component->id->name;
throw RateLimitExceededException::forAction(
componentName: $componentName,
action: $method,
limit: $result->getLimit(),
current: $result->getCurrent(),
retryAfter: $result->getRetryAfter() ?? 60
);
}
}
/**
* Get RequiresPermission attribute from action method
*
* @return RequiresPermission|null Permission attribute if present
*/
private function getRequiresPermissionAttribute(
LiveComponentContract $component,
string $method
): ?RequiresPermission {
try {
$reflection = new \ReflectionMethod($component, $method);
$attributes = $reflection->getAttributes(RequiresPermission::class);
if (empty($attributes)) {
return null;
}
return $attributes[0]->newInstance();
} catch (\ReflectionException $e) {
// Method doesn't exist or is not accessible
return null;
}
}
/**
* Get or derive schema for component
*
* Uses SchemaCache for performance - derives schema once per component class.
* Schema is derived from first getState() call and cached.
*
* @param LiveComponentContract $component Component to get schema for
* @return DerivedSchema Cached or newly derived schema
*/
private function getOrDeriveSchema(LiveComponentContract $component): DerivedSchema
{
$componentClass = get_class($component);
// Check cache
$cachedSchema = $this->schemaCache->get($componentClass);
if ($cachedSchema !== null) {
return $cachedSchema;
}
// Derive schema from current component state
$currentState = $component->state;
$schema = $this->stateValidator->deriveSchemaFromState($currentState);
// Cache for future use
$this->schemaCache->set($componentClass, $schema);
return $schema;
}
/**
* Call onUpdate() lifecycle hook if component implements LifecycleAware
*/
private function callUpdateHook(
LiveComponentContract $component,
object $newState
): void {
if (! $component instanceof \App\Framework\LiveComponents\Contracts\LifecycleAware) {
return;
}
try {
$component->onUpdate();
} catch (\Throwable $e) {
// Log lifecycle hook errors but don't fail the action
// Components should handle their own errors in hooks
error_log("Lifecycle hook onUpdate() failed for " . get_class($component) . ": " . $e->getMessage());
}
}
/**
* Call onMount() lifecycle hook if component implements LifecycleAware
*
* This should be called by ComponentRegistry after first component creation.
*/
public function callMountHook(LiveComponentContract $component): void
{
if (! $component instanceof \App\Framework\LiveComponents\Contracts\LifecycleAware) {
return;
}
$this->performanceTracker->measure(
"livecomponent.lifecycle.onMount",
PerformanceCategory::CUSTOM,
function () use ($component): void {
try {
$component->onMount();
} catch (\Throwable $e) {
// Log lifecycle hook errors but don't fail the creation
error_log("Lifecycle hook onMount() failed for " . get_class($component) . ": " . $e->getMessage());
}
},
['component' => $component->id->name]
);
}
}