diff --git a/src/Fetch/Concerns/ManagesRetries.php b/src/Fetch/Concerns/ManagesRetries.php index 293105c..57e0e30 100644 --- a/src/Fetch/Concerns/ManagesRetries.php +++ b/src/Fetch/Concerns/ManagesRetries.php @@ -4,11 +4,14 @@ namespace Fetch\Concerns; +use Fetch\Events\ErrorEvent; +use Fetch\Events\RetryEvent; use Fetch\Exceptions\RequestException as FetchRequestException; use Fetch\Interfaces\ClientHandler; use Fetch\Interfaces\Response as ResponseInterface; use GuzzleHttp\Exception\ConnectException; use InvalidArgumentException; +use Psr\Http\Message\RequestInterface; use RuntimeException; use Throwable; @@ -261,4 +264,119 @@ protected function isRetryableError(Throwable $e): bool return $isRetryableStatusCode || $isRetryableException; } + + /** + * Implement retry logic for the request with event dispatching. + * + * @param callable $request The request to execute + * @param RequestInterface $psrRequest The PSR-7 request for events + * @param string $correlationId The correlation ID for event tracking + * @return ResponseInterface The response after successful execution + * + * @throws FetchRequestException If the request fails after all retries + * @throws RuntimeException If something unexpected happens + */ + protected function retryRequestWithEvents( + callable $request, + RequestInterface $psrRequest, + string $correlationId + ): ResponseInterface { + $attempts = $this->maxRetries ?? self::DEFAULT_RETRIES; + $delay = $this->retryDelay ?? self::DEFAULT_RETRY_DELAY; + $exceptions = []; + + for ($attempt = 0; $attempt <= $attempts; $attempt++) { + try { + // Execute the request + return $request(); + } catch (Throwable $e) { + // Collect exception for later + $exceptions[] = $e; + + // Get response from exception if available + $response = null; + if ($e instanceof FetchRequestException && $e->getResponse()) { + $response = $e->getResponse(); + } elseif (method_exists($e, 'getResponse')) { + $response = $e->getResponse(); + } + + // If this was the last attempt, dispatch error event and break + if ($attempt === $attempts) { + // Dispatch error event + if (method_exists($this, 'dispatchEvent')) { + $this->dispatchEvent(new ErrorEvent( + $psrRequest, + $e, + $correlationId, + microtime(true), + $attempt + 1, + $response + )); + } + break; + } + + // Only retry on retryable errors + if (! $this->isRetryableError($e)) { + // Dispatch error event for non-retryable errors + if (method_exists($this, 'dispatchEvent')) { + $this->dispatchEvent(new ErrorEvent( + $psrRequest, + $e, + $correlationId, + microtime(true), + $attempt + 1, + $response + )); + } + throw $e; + } + + // Log the retry for debugging purposes + if (method_exists($this, 'logRetry')) { + $this->logRetry($attempt + 1, $attempts, $e); + } + + // Calculate delay with exponential backoff and jitter + $currentDelay = $this->calculateBackoffDelay($delay, $attempt); + + // Dispatch retry event + if (method_exists($this, 'dispatchEvent')) { + $this->dispatchEvent(new RetryEvent( + $psrRequest, + $e, + $attempt + 1, + $attempts, + $currentDelay, + $correlationId, + microtime(true) + )); + } + + // Sleep before the next retry + usleep($currentDelay * 1000); // Convert milliseconds to microseconds + } + } + + // If we got here, all retries failed + $lastException = end($exceptions) ?: new RuntimeException('Request failed after all retries'); + + // Enhanced failure reporting + if ($lastException instanceof FetchRequestException && $lastException->getResponse()) { + $statusCode = $lastException->getResponse()->getStatusCode(); + throw new RuntimeException( + sprintf( + 'Request failed after %d attempts with status code %d: %s', + $attempts + 1, + $statusCode, + $lastException->getMessage() + ), + $statusCode, + $lastException + ); + } + + throw $lastException; + } } diff --git a/src/Fetch/Concerns/PerformsHttpRequests.php b/src/Fetch/Concerns/PerformsHttpRequests.php index aac842a..89f844e 100644 --- a/src/Fetch/Concerns/PerformsHttpRequests.php +++ b/src/Fetch/Concerns/PerformsHttpRequests.php @@ -6,6 +6,8 @@ use Fetch\Enum\ContentType; use Fetch\Enum\Method; +use Fetch\Events\RequestEvent; +use Fetch\Events\ResponseEvent; use Fetch\Exceptions\RequestException as FetchRequestException; use Fetch\Http\Response; use Fetch\Interfaces\Response as ResponseInterface; @@ -182,16 +184,33 @@ public function sendRequest( // Start timing for logging $startTime = microtime(true); + // Generate a correlation ID for event tracking + $correlationId = method_exists($handler, 'generateCorrelationId') + ? $handler->generateCorrelationId() + : bin2hex(random_bytes(16)); + // Log the request if method exists if (method_exists($handler, 'logRequest')) { $handler->logRequest($methodStr, $fullUri, $guzzleOptions); } + // Dispatch request event + if (method_exists($handler, 'dispatchEvent')) { + $psrRequest = new GuzzleRequest($methodStr, $fullUri, $guzzleOptions['headers'] ?? []); + $handler->dispatchEvent(new RequestEvent( + $psrRequest, + $correlationId, + $startTime, + [], + $guzzleOptions + )); + } + // Send the request (async or sync) if ($handler->isAsync) { - return $handler->executeAsyncRequest($methodStr, $fullUri, $guzzleOptions); + return $handler->executeAsyncRequest($methodStr, $fullUri, $guzzleOptions, $correlationId); } else { - return $handler->executeSyncRequest($methodStr, $fullUri, $guzzleOptions, $startTime); + return $handler->executeSyncRequest($methodStr, $fullUri, $guzzleOptions, $startTime, $correlationId); } } @@ -336,6 +355,7 @@ protected function prepareGuzzleOptions(): array * @param string $uri The full URI * @param array $options The Guzzle options * @param float $startTime The request start time + * @param string|null $correlationId The correlation ID for event tracking * @return ResponseInterface The response */ protected function executeSyncRequest( @@ -343,7 +363,11 @@ protected function executeSyncRequest( string $uri, array $options, float $startTime, + ?string $correlationId = null, ): ResponseInterface { + // Generate correlation ID if not provided + $correlationId = $correlationId ?? bin2hex(random_bytes(16)); + // Start profiling if profiler is available $requestId = null; if (method_exists($this, 'startProfiling')) { @@ -353,79 +377,92 @@ protected function executeSyncRequest( // Track memory for debugging $startMemory = memory_get_usage(true); - return $this->retryRequest(function () use ($method, $uri, $options, $startTime, $requestId, $startMemory): ResponseInterface { - try { - // Record request sent event for profiling - if ($requestId !== null && method_exists($this, 'recordProfilingEvent')) { - $this->recordProfilingEvent($requestId, 'request_sent'); - } + // Create the PSR request for events + $psrRequest = new GuzzleRequest($method, $uri, $options['headers'] ?? []); - // Send the request to Guzzle - $psrResponse = $this->getHttpClient()->request($method, $uri, $options); + return $this->retryRequestWithEvents( + function () use ($method, $uri, $options, $startTime, $requestId, $startMemory, $psrRequest, $correlationId): ResponseInterface { + try { + // Record request sent event for profiling + if ($requestId !== null && method_exists($this, 'recordProfilingEvent')) { + $this->recordProfilingEvent($requestId, 'request_sent'); + } - // Record response received event for profiling - if ($requestId !== null && method_exists($this, 'recordProfilingEvent')) { - $this->recordProfilingEvent($requestId, 'response_start'); - } + // Send the request to Guzzle + $psrResponse = $this->getHttpClient()->request($method, $uri, $options); - // Calculate duration - $duration = microtime(true) - $startTime; + // Record response received event for profiling + if ($requestId !== null && method_exists($this, 'recordProfilingEvent')) { + $this->recordProfilingEvent($requestId, 'response_start'); + } - // Create our response object - $response = Response::createFromBase($psrResponse); + // Calculate duration + $duration = microtime(true) - $startTime; - // End profiling - if ($requestId !== null && method_exists($this, 'endProfiling')) { - $this->endProfiling($requestId, $response->getStatusCode()); - } + // Create our response object + $response = Response::createFromBase($psrResponse); - // Create debug info if debug mode is enabled - if (method_exists($this, 'isDebugEnabled') && $this->isDebugEnabled()) { - $memoryUsage = memory_get_usage(true) - $startMemory; - $timings = [ - 'total_time' => round($duration * 1000, 3), - 'start_time' => $startTime, - 'end_time' => microtime(true), - ]; - - if (method_exists($this, 'createDebugInfo')) { - $this->createDebugInfo($method, $uri, $options, $response, $timings, $memoryUsage); + // End profiling + if ($requestId !== null && method_exists($this, 'endProfiling')) { + $this->endProfiling($requestId, $response->getStatusCode()); } - } - // Trigger retry on configured retryable status codes - if (in_array($response->getStatusCode(), $this->getRetryableStatusCodes(), true)) { - $psrRequest = new GuzzleRequest($method, $uri, $options['headers'] ?? []); + // Create debug info if debug mode is enabled + if (method_exists($this, 'isDebugEnabled') && $this->isDebugEnabled()) { + $memoryUsage = memory_get_usage(true) - $startMemory; + $timings = [ + 'total_time' => round($duration * 1000, 3), + 'start_time' => $startTime, + 'end_time' => microtime(true), + ]; + + if (method_exists($this, 'createDebugInfo')) { + $this->createDebugInfo($method, $uri, $options, $response, $timings, $memoryUsage); + } + } - throw new FetchRequestException('Retryable status: '.$response->getStatusCode(), $psrRequest, $psrResponse); - } + // Trigger retry on configured retryable status codes + if (in_array($response->getStatusCode(), $this->getRetryableStatusCodes(), true)) { + throw new FetchRequestException('Retryable status: '.$response->getStatusCode(), $psrRequest, $psrResponse); + } - // Log response if method exists - if (method_exists($this, 'logResponse')) { - $this->logResponse($response, $duration); - } + // Log response if method exists + if (method_exists($this, 'logResponse')) { + $this->logResponse($response, $duration); + } - return $response; - } catch (GuzzleException $e) { - // End profiling with error - if ($requestId !== null && method_exists($this, 'endProfiling')) { - $this->endProfiling($requestId, null); - } + // Dispatch response event + if (method_exists($this, 'dispatchEvent')) { + $this->dispatchEvent(new ResponseEvent( + $psrRequest, + $response, + $correlationId, + microtime(true), + $duration + )); + } - // Normalize to Fetch RequestException to participate in retry logic - if ($e instanceof GuzzleRequestException) { - $req = $e->getRequest(); - $res = $e->getResponse(); + return $response; + } catch (GuzzleException $e) { + // End profiling with error + if ($requestId !== null && method_exists($this, 'endProfiling')) { + $this->endProfiling($requestId, null); + } - throw new FetchRequestException(sprintf('Request %s %s failed: %s', $method, $uri, $e->getMessage()), $req, $res, $e); - } + // Normalize to Fetch RequestException to participate in retry logic + if ($e instanceof GuzzleRequestException) { + $req = $e->getRequest(); + $res = $e->getResponse(); - // Fallback when we don't get a Guzzle RequestException (no request available) - $psrRequest = new GuzzleRequest($method, $uri, $options['headers'] ?? []); + throw new FetchRequestException(sprintf('Request %s %s failed: %s', $method, $uri, $e->getMessage()), $req, $res, $e); + } - throw new FetchRequestException(sprintf('Request %s %s failed: %s', $method, $uri, $e->getMessage()), $psrRequest, null, $e); - } - }); + throw new FetchRequestException(sprintf('Request %s %s failed: %s', $method, $uri, $e->getMessage()), $psrRequest, null, $e); + } + }, + $psrRequest, + $correlationId + ); } /** @@ -434,20 +471,23 @@ protected function executeSyncRequest( * @param string $method The HTTP method * @param string $uri The full URI * @param array $options The Guzzle options + * @param string|null $correlationId The correlation ID for event tracking * @return PromiseInterface A promise that resolves with the response */ protected function executeAsyncRequest( string $method, string $uri, array $options, + ?string $correlationId = null, ): PromiseInterface { - return async(function () use ($method, $uri, $options): ResponseInterface { + return async(function () use ($method, $uri, $options, $correlationId): ResponseInterface { $startTime = microtime(true); + $correlationId = $correlationId ?? bin2hex(random_bytes(16)); // Since this is in an async context, we can use try-catch for proper promise rejection try { // Execute the synchronous request inside the async function - $response = $this->executeSyncRequest($method, $uri, $options, $startTime); + $response = $this->executeSyncRequest($method, $uri, $options, $startTime, $correlationId); return $response; } catch (\Throwable $e) { diff --git a/src/Fetch/Concerns/SupportsHooks.php b/src/Fetch/Concerns/SupportsHooks.php new file mode 100644 index 0000000..bbc5226 --- /dev/null +++ b/src/Fetch/Concerns/SupportsHooks.php @@ -0,0 +1,254 @@ + + */ + protected static array $hookNameMappings = [ + 'before_send' => 'request.sending', + 'after_response' => 'response.received', + 'on_error' => 'error.occurred', + 'on_retry' => 'request.retrying', + 'on_timeout' => 'request.timeout', + 'on_redirect' => 'request.redirecting', + ]; + + /** + * Register a callback for when a request is about to be sent. + * + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function onRequest(callable $callback, int $priority = 0): static + { + $this->getEventDispatcher()->addListener('request.sending', $callback, $priority); + + return $this; + } + + /** + * Register a callback for when a response is received. + * + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function onResponse(callable $callback, int $priority = 0): static + { + $this->getEventDispatcher()->addListener('response.received', $callback, $priority); + + return $this; + } + + /** + * Register a callback for when an error occurs. + * + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function onError(callable $callback, int $priority = 0): static + { + $this->getEventDispatcher()->addListener('error.occurred', $callback, $priority); + + return $this; + } + + /** + * Register a callback for when a request is being retried. + * + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function onRetry(callable $callback, int $priority = 0): static + { + $this->getEventDispatcher()->addListener('request.retrying', $callback, $priority); + + return $this; + } + + /** + * Register a callback for when a request times out. + * + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function onTimeout(callable $callback, int $priority = 0): static + { + $this->getEventDispatcher()->addListener('request.timeout', $callback, $priority); + + return $this; + } + + /** + * Register a callback for when a request is being redirected. + * + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function onRedirect(callable $callback, int $priority = 0): static + { + $this->getEventDispatcher()->addListener('request.redirecting', $callback, $priority); + + return $this; + } + + /** + * Register a callback for a specific event. + * + * @param string $eventName The event name to listen for + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function when(string $eventName, callable $callback, int $priority = 0): static + { + $this->getEventDispatcher()->addListener($eventName, $callback, $priority); + + return $this; + } + + /** + * Register multiple hooks at once. + * + * @param array $hooks Array of hook name => callback pairs + * @return $this + */ + public function hooks(array $hooks): static + { + foreach ($hooks as $hook => $callback) { + $eventName = $this->normalizeHookName($hook); + $this->getEventDispatcher()->addListener($eventName, $callback); + } + + return $this; + } + + /** + * Get the event dispatcher instance. + */ + public function getEventDispatcher(): EventDispatcherInterface + { + if ($this->eventDispatcher === null) { + $this->eventDispatcher = new EventDispatcher($this->logger ?? null); + } + + return $this->eventDispatcher; + } + + /** + * Set a custom event dispatcher. + * + * @param EventDispatcherInterface $dispatcher The event dispatcher to use + * @return $this + */ + public function setEventDispatcher(EventDispatcherInterface $dispatcher): static + { + $this->eventDispatcher = $dispatcher; + + return $this; + } + + /** + * Check if event hooks are registered. + * + * @param string|null $eventName The event name, or null to check any + */ + public function hasHooks(?string $eventName = null): bool + { + if ($this->eventDispatcher === null) { + return false; + } + + if ($eventName === null) { + // Check if any listeners are registered + $events = [ + 'request.sending', + 'response.received', + 'error.occurred', + 'request.retrying', + 'request.timeout', + 'request.redirecting', + ]; + + foreach ($events as $event) { + if ($this->eventDispatcher->hasListeners($event)) { + return true; + } + } + + return false; + } + + return $this->eventDispatcher->hasListeners($eventName); + } + + /** + * Clear all event hooks. + * + * @param string|null $eventName The event name, or null to clear all + * @return $this + */ + public function clearHooks(?string $eventName = null): static + { + if ($this->eventDispatcher !== null) { + $this->eventDispatcher->clearListeners($eventName); + } + + return $this; + } + + /** + * Dispatch an event to all registered listeners. + * + * @param FetchEvent $event The event to dispatch + */ + protected function dispatchEvent(FetchEvent $event): void + { + if ($this->eventDispatcher !== null) { + $this->eventDispatcher->dispatch($event); + } + } + + /** + * Normalize a hook name to the full event name. + * + * @param string $hookName The shorthand or full hook name + * @return string The normalized event name + */ + protected function normalizeHookName(string $hookName): string + { + return self::$hookNameMappings[$hookName] ?? $hookName; + } + + /** + * Generate a correlation ID for tracking related events. + */ + protected function generateCorrelationId(): string + { + return bin2hex(random_bytes(16)); + } +} diff --git a/src/Fetch/Events/ErrorEvent.php b/src/Fetch/Events/ErrorEvent.php new file mode 100644 index 0000000..a614b0d --- /dev/null +++ b/src/Fetch/Events/ErrorEvent.php @@ -0,0 +1,100 @@ + $context Additional contextual data + */ + public function __construct( + RequestInterface $request, + protected Throwable $exception, + string $correlationId, + float $timestamp, + protected int $attempt = 1, + protected ?ResponseInterface $response = null, + array $context = [] + ) { + parent::__construct($request, $correlationId, $timestamp, $context); + } + + /** + * Get the event name. + */ + public function getName(): string + { + return 'error.occurred'; + } + + /** + * Get the exception that was thrown. + */ + public function getException(): Throwable + { + return $this->exception; + } + + /** + * Get the attempt number when the error occurred. + */ + public function getAttempt(): int + { + return $this->attempt; + } + + /** + * Get the HTTP response if one was received. + */ + public function getResponse(): ?ResponseInterface + { + return $this->response; + } + + /** + * Check if this error is retryable. + * + * An error is considered retryable if it's a network error, + * timeout, or returns a retryable HTTP status code. + */ + public function isRetryable(): bool + { + // Check if response has a retryable status code + if ($this->response !== null) { + $statusCode = $this->response->getStatusCode(); + + return in_array($statusCode, self::RETRYABLE_STATUS_CODES, true); + } + + // Network errors are typically retryable + return true; + } + + /** + * Default list of HTTP status codes that are considered retryable. + * + * @var array + */ + public const RETRYABLE_STATUS_CODES = [ + 408, 429, 500, 502, 503, + 504, 507, 509, 520, 521, + 522, 523, 525, 527, 530, + ]; +} diff --git a/src/Fetch/Events/EventDispatcher.php b/src/Fetch/Events/EventDispatcher.php new file mode 100644 index 0000000..158353c --- /dev/null +++ b/src/Fetch/Events/EventDispatcher.php @@ -0,0 +1,182 @@ + [priority => [listeners]]] + * + * @var array>> + */ + private array $listeners = []; + + /** + * Cached sorted listeners by event name. + * + * @var array> + */ + private array $sorted = []; + + /** + * Logger for recording listener errors. + */ + private LoggerInterface $logger; + + /** + * Create a new event dispatcher instance. + * + * @param LoggerInterface|null $logger Optional logger for error reporting + */ + public function __construct(?LoggerInterface $logger = null) + { + $this->logger = $logger ?? new NullLogger; + } + + /** + * Add a listener for a specific event. + * + * @param string $eventName The name of the event to listen for + * @param callable $listener The callback to invoke when the event is dispatched + * @param int $priority Higher priority listeners are called first (default: 0) + */ + public function addListener(string $eventName, callable $listener, int $priority = 0): void + { + $this->listeners[$eventName][$priority][] = $listener; + + // Clear the sorted cache for this event + unset($this->sorted[$eventName]); + } + + /** + * Remove a listener for a specific event. + * + * @param string $eventName The name of the event + * @param callable $listener The callback to remove + */ + public function removeListener(string $eventName, callable $listener): void + { + if (! isset($this->listeners[$eventName])) { + return; + } + + foreach ($this->listeners[$eventName] as $priority => $listeners) { + foreach ($listeners as $key => $registeredListener) { + if ($registeredListener === $listener) { + unset($this->listeners[$eventName][$priority][$key]); + + // Clean up empty priority arrays + if (empty($this->listeners[$eventName][$priority])) { + unset($this->listeners[$eventName][$priority]); + } + + // Clean up empty event arrays + if (empty($this->listeners[$eventName])) { + unset($this->listeners[$eventName]); + } + + // Clear the sorted cache for this event + unset($this->sorted[$eventName]); + + return; + } + } + } + } + + /** + * Dispatch an event to all registered listeners. + * + * @param FetchEvent $event The event to dispatch + */ + public function dispatch(FetchEvent $event): void + { + $eventName = $event->getName(); + $listeners = $this->getListeners($eventName); + + foreach ($listeners as $listener) { + try { + $listener($event); + } catch (Throwable $e) { + // Log listener errors but don't stop event propagation + $this->logger->error('Event listener error', [ + 'event' => $eventName, + 'error' => $e->getMessage(), + 'correlation_id' => $event->getCorrelationId(), + ]); + } + } + } + + /** + * Check if there are any listeners registered for an event. + * + * @param string $eventName The name of the event + * @return bool True if there are listeners registered + */ + public function hasListeners(string $eventName): bool + { + return ! empty($this->listeners[$eventName]); + } + + /** + * Get all listeners for a specific event, sorted by priority. + * + * @param string $eventName The name of the event + * @return array The registered listeners, sorted by priority (highest first) + */ + public function getListeners(string $eventName): array + { + if (! isset($this->listeners[$eventName])) { + return []; + } + + // Return cached sorted listeners if available + if (isset($this->sorted[$eventName])) { + return $this->sorted[$eventName]; + } + + // Sort by priority (highest first) + $prioritized = $this->listeners[$eventName]; + krsort($prioritized); + + // Flatten the array + $sorted = []; + foreach ($prioritized as $listeners) { + foreach ($listeners as $listener) { + $sorted[] = $listener; + } + } + + // Cache and return + $this->sorted[$eventName] = $sorted; + + return $sorted; + } + + /** + * Remove all listeners for a specific event or all events. + * + * @param string|null $eventName The event name, or null to clear all listeners + */ + public function clearListeners(?string $eventName = null): void + { + if ($eventName === null) { + $this->listeners = []; + $this->sorted = []; + } else { + unset($this->listeners[$eventName], $this->sorted[$eventName]); + } + } +} diff --git a/src/Fetch/Events/EventDispatcherInterface.php b/src/Fetch/Events/EventDispatcherInterface.php new file mode 100644 index 0000000..7911602 --- /dev/null +++ b/src/Fetch/Events/EventDispatcherInterface.php @@ -0,0 +1,58 @@ + The registered listeners, sorted by priority + */ + public function getListeners(string $eventName): array; + + /** + * Remove all listeners for a specific event or all events. + * + * @param string|null $eventName The event name, or null to clear all listeners + */ + public function clearListeners(?string $eventName = null): void; +} diff --git a/src/Fetch/Events/FetchEvent.php b/src/Fetch/Events/FetchEvent.php new file mode 100644 index 0000000..9ce4ced --- /dev/null +++ b/src/Fetch/Events/FetchEvent.php @@ -0,0 +1,77 @@ + $context Additional contextual data + */ + public function __construct( + protected RequestInterface $request, + protected string $correlationId, + protected float $timestamp, + protected array $context = [] + ) {} + + /** + * Get the HTTP request associated with this event. + */ + public function getRequest(): RequestInterface + { + return $this->request; + } + + /** + * Get the correlation ID for this event. + * + * The correlation ID is used to relate multiple events that belong + * to the same HTTP request lifecycle. + */ + public function getCorrelationId(): string + { + return $this->correlationId; + } + + /** + * Get the timestamp when this event occurred. + * + * @return float Unix timestamp with microseconds + */ + public function getTimestamp(): float + { + return $this->timestamp; + } + + /** + * Get the additional context data for this event. + * + * @return array + */ + public function getContext(): array + { + return $this->context; + } + + /** + * Get the name of this event. + * + * Event names follow the format: category.action + * Examples: request.sending, response.received, error.occurred + */ + abstract public function getName(): string; +} diff --git a/src/Fetch/Events/RedirectEvent.php b/src/Fetch/Events/RedirectEvent.php new file mode 100644 index 0000000..a936433 --- /dev/null +++ b/src/Fetch/Events/RedirectEvent.php @@ -0,0 +1,69 @@ + $context Additional contextual data + */ + public function __construct( + RequestInterface $request, + protected ResponseInterface $response, + protected string $location, + protected int $redirectCount, + string $correlationId, + float $timestamp, + array $context = [] + ) { + parent::__construct($request, $correlationId, $timestamp, $context); + } + + /** + * Get the event name. + */ + public function getName(): string + { + return 'request.redirecting'; + } + + /** + * Get the redirect response. + */ + public function getResponse(): ResponseInterface + { + return $this->response; + } + + /** + * Get the target location for the redirect. + */ + public function getLocation(): string + { + return $this->location; + } + + /** + * Get the current redirect count (1-based). + */ + public function getRedirectCount(): int + { + return $this->redirectCount; + } +} diff --git a/src/Fetch/Events/RequestEvent.php b/src/Fetch/Events/RequestEvent.php new file mode 100644 index 0000000..4202562 --- /dev/null +++ b/src/Fetch/Events/RequestEvent.php @@ -0,0 +1,50 @@ + $context Additional contextual data + * @param array $options Request options being used + */ + public function __construct( + RequestInterface $request, + string $correlationId, + float $timestamp, + array $context = [], + protected array $options = [] + ) { + parent::__construct($request, $correlationId, $timestamp, $context); + } + + /** + * Get the event name. + */ + public function getName(): string + { + return 'request.sending'; + } + + /** + * Get the request options being used. + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } +} diff --git a/src/Fetch/Events/ResponseEvent.php b/src/Fetch/Events/ResponseEvent.php new file mode 100644 index 0000000..fb64819 --- /dev/null +++ b/src/Fetch/Events/ResponseEvent.php @@ -0,0 +1,67 @@ + $context Additional contextual data + */ + public function __construct( + RequestInterface $request, + protected ResponseInterface $response, + string $correlationId, + float $timestamp, + protected float $duration, + array $context = [] + ) { + parent::__construct($request, $correlationId, $timestamp, $context); + } + + /** + * Get the event name. + */ + public function getName(): string + { + return 'response.received'; + } + + /** + * Get the HTTP response. + */ + public function getResponse(): ResponseInterface + { + return $this->response; + } + + /** + * Get the request duration in seconds. + */ + public function getDuration(): float + { + return $this->duration; + } + + /** + * Get the request latency in milliseconds. + */ + public function getLatency(): int + { + return (int) ($this->duration * 1000); + } +} diff --git a/src/Fetch/Events/RetryEvent.php b/src/Fetch/Events/RetryEvent.php new file mode 100644 index 0000000..20a081e --- /dev/null +++ b/src/Fetch/Events/RetryEvent.php @@ -0,0 +1,87 @@ + $context Additional contextual data + */ + public function __construct( + RequestInterface $request, + protected Throwable $previousException, + protected int $attempt, + protected int $maxAttempts, + protected int $delay, + string $correlationId, + float $timestamp, + array $context = [] + ) { + parent::__construct($request, $correlationId, $timestamp, $context); + } + + /** + * Get the event name. + */ + public function getName(): string + { + return 'request.retrying'; + } + + /** + * Get the exception from the previous attempt. + */ + public function getPreviousException(): Throwable + { + return $this->previousException; + } + + /** + * Get the current attempt number (1-based). + */ + public function getAttempt(): int + { + return $this->attempt; + } + + /** + * Get the maximum number of attempts allowed. + */ + public function getMaxAttempts(): int + { + return $this->maxAttempts; + } + + /** + * Get the delay in milliseconds before the retry. + */ + public function getDelay(): int + { + return $this->delay; + } + + /** + * Check if this is the last attempt. + */ + public function isLastAttempt(): bool + { + return $this->attempt >= $this->maxAttempts; + } +} diff --git a/src/Fetch/Events/TimeoutEvent.php b/src/Fetch/Events/TimeoutEvent.php new file mode 100644 index 0000000..cdfa6ce --- /dev/null +++ b/src/Fetch/Events/TimeoutEvent.php @@ -0,0 +1,58 @@ + $context Additional contextual data + */ + public function __construct( + RequestInterface $request, + protected int $timeout, + protected float $elapsed, + string $correlationId, + float $timestamp, + array $context = [] + ) { + parent::__construct($request, $correlationId, $timestamp, $context); + } + + /** + * Get the event name. + */ + public function getName(): string + { + return 'request.timeout'; + } + + /** + * Get the configured timeout in seconds. + */ + public function getTimeout(): int + { + return $this->timeout; + } + + /** + * Get the actual elapsed time in seconds. + */ + public function getElapsed(): float + { + return $this->elapsed; + } +} diff --git a/src/Fetch/Http/ClientHandler.php b/src/Fetch/Http/ClientHandler.php index d6dbc82..7a445a1 100644 --- a/src/Fetch/Http/ClientHandler.php +++ b/src/Fetch/Http/ClientHandler.php @@ -11,6 +11,7 @@ use Fetch\Concerns\ManagesPromises; use Fetch\Concerns\ManagesRetries; use Fetch\Concerns\PerformsHttpRequests; +use Fetch\Concerns\SupportsHooks; use Fetch\Enum\ContentType; use Fetch\Enum\Method; use Fetch\Interfaces\ClientHandler as ClientHandlerInterface; @@ -32,7 +33,8 @@ class ClientHandler implements ClientHandlerInterface ManagesDebugAndProfiling, ManagesPromises, ManagesRetries, - PerformsHttpRequests; + PerformsHttpRequests, + SupportsHooks; /** * Default options for the request. diff --git a/src/Fetch/Interfaces/ClientHandler.php b/src/Fetch/Interfaces/ClientHandler.php index 142a3d6..dcfdfbd 100644 --- a/src/Fetch/Interfaces/ClientHandler.php +++ b/src/Fetch/Interfaces/ClientHandler.php @@ -6,6 +6,7 @@ use Fetch\Enum\ContentType; use Fetch\Enum\Method; +use Fetch\Events\EventDispatcherInterface; use GuzzleHttp\ClientInterface; use GuzzleHttp\Cookie\CookieJarInterface; use Psr\Log\LoggerInterface; @@ -573,4 +574,104 @@ public function getDebugOptions(): array; * Get the last debug info from the most recent request. */ public function getLastDebugInfo(): ?\Fetch\Support\DebugInfo; + + /** + * Register a callback for when a request is about to be sent. + * + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function onRequest(callable $callback, int $priority = 0): self; + + /** + * Register a callback for when a response is received. + * + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function onResponse(callable $callback, int $priority = 0): self; + + /** + * Register a callback for when an error occurs. + * + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function onError(callable $callback, int $priority = 0): self; + + /** + * Register a callback for when a request is being retried. + * + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function onRetry(callable $callback, int $priority = 0): self; + + /** + * Register a callback for when a request times out. + * + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function onTimeout(callable $callback, int $priority = 0): self; + + /** + * Register a callback for when a request is being redirected. + * + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function onRedirect(callable $callback, int $priority = 0): self; + + /** + * Register a callback for a specific event. + * + * @param string $eventName The event name to listen for + * @param callable $callback The callback to invoke + * @param int $priority Higher priority callbacks are called first + * @return $this + */ + public function when(string $eventName, callable $callback, int $priority = 0): self; + + /** + * Register multiple hooks at once. + * + * @param array $hooks Array of hook name => callback pairs + * @return $this + */ + public function hooks(array $hooks): self; + + /** + * Get the event dispatcher instance. + */ + public function getEventDispatcher(): EventDispatcherInterface; + + /** + * Set a custom event dispatcher. + * + * @param EventDispatcherInterface $dispatcher The event dispatcher to use + * @return $this + */ + public function setEventDispatcher(EventDispatcherInterface $dispatcher): self; + + /** + * Check if event hooks are registered. + * + * @param string|null $eventName The event name, or null to check any + */ + public function hasHooks(?string $eventName = null): bool; + + /** + * Clear all event hooks. + * + * @param string|null $eventName The event name, or null to clear all + * @return $this + */ + public function clearHooks(?string $eventName = null): self; } diff --git a/tests/Unit/Events/EventDispatcherTest.php b/tests/Unit/Events/EventDispatcherTest.php new file mode 100644 index 0000000..f142545 --- /dev/null +++ b/tests/Unit/Events/EventDispatcherTest.php @@ -0,0 +1,257 @@ +dispatcher = new EventDispatcher; + } + + public function test_add_listener() + { + $called = false; + $callback = function () use (&$called) { + $called = true; + }; + + $this->dispatcher->addListener('request.sending', $callback); + + $this->assertTrue($this->dispatcher->hasListeners('request.sending')); + } + + public function test_dispatch_calls_listeners() + { + $called = false; + $receivedEvent = null; + + $this->dispatcher->addListener('request.sending', function (FetchEvent $event) use (&$called, &$receivedEvent) { + $called = true; + $receivedEvent = $event; + }); + + $request = new Request('GET', 'https://example.com/api'); + $event = new RequestEvent($request, 'corr-123', microtime(true)); + + $this->dispatcher->dispatch($event); + + $this->assertTrue($called); + $this->assertSame($event, $receivedEvent); + } + + public function test_multiple_listeners_are_called() + { + $order = []; + + $this->dispatcher->addListener('response.received', function () use (&$order) { + $order[] = 'first'; + }); + + $this->dispatcher->addListener('response.received', function () use (&$order) { + $order[] = 'second'; + }); + + $request = new Request('GET', 'https://example.com/api'); + $event = new RequestEvent($request, 'corr-123', microtime(true)); + // Set the event name by using a real RequestEvent (which has 'request.sending') + // We need to test with an event that matches the listener + $this->dispatcher->dispatch($event); + + // Nothing was called because we registered for 'response.received' but dispatched 'request.sending' + $this->assertEmpty($order); + + // Now let's test properly + $this->dispatcher->clearListeners(); + + $this->dispatcher->addListener('request.sending', function () use (&$order) { + $order[] = 'first'; + }); + + $this->dispatcher->addListener('request.sending', function () use (&$order) { + $order[] = 'second'; + }); + + $this->dispatcher->dispatch($event); + + $this->assertEquals(['first', 'second'], $order); + } + + public function test_priority_ordering() + { + $order = []; + + $this->dispatcher->addListener('request.sending', function () use (&$order) { + $order[] = 'low'; + }, 1); + + $this->dispatcher->addListener('request.sending', function () use (&$order) { + $order[] = 'high'; + }, 10); + + $this->dispatcher->addListener('request.sending', function () use (&$order) { + $order[] = 'medium'; + }, 5); + + $request = new Request('GET', 'https://example.com/api'); + $event = new RequestEvent($request, 'corr-123', microtime(true)); + + $this->dispatcher->dispatch($event); + + $this->assertEquals(['high', 'medium', 'low'], $order); + } + + public function test_remove_listener() + { + $callback = function () {}; + + $this->dispatcher->addListener('request.sending', $callback); + $this->assertTrue($this->dispatcher->hasListeners('request.sending')); + + $this->dispatcher->removeListener('request.sending', $callback); + $this->assertFalse($this->dispatcher->hasListeners('request.sending')); + } + + public function test_has_listeners_returns_false_for_empty() + { + $this->assertFalse($this->dispatcher->hasListeners('nonexistent.event')); + } + + public function test_get_listeners_returns_empty_array_for_no_listeners() + { + $listeners = $this->dispatcher->getListeners('nonexistent.event'); + $this->assertEmpty($listeners); + } + + public function test_get_listeners_returns_sorted_listeners() + { + $low = function () {}; + $high = function () {}; + $medium = function () {}; + + $this->dispatcher->addListener('request.sending', $low, 1); + $this->dispatcher->addListener('request.sending', $high, 10); + $this->dispatcher->addListener('request.sending', $medium, 5); + + $listeners = $this->dispatcher->getListeners('request.sending'); + + $this->assertCount(3, $listeners); + $this->assertSame($high, $listeners[0]); + $this->assertSame($medium, $listeners[1]); + $this->assertSame($low, $listeners[2]); + } + + public function test_clear_listeners_for_specific_event() + { + $this->dispatcher->addListener('request.sending', function () {}); + $this->dispatcher->addListener('response.received', function () {}); + + $this->dispatcher->clearListeners('request.sending'); + + $this->assertFalse($this->dispatcher->hasListeners('request.sending')); + $this->assertTrue($this->dispatcher->hasListeners('response.received')); + } + + public function test_clear_all_listeners() + { + $this->dispatcher->addListener('request.sending', function () {}); + $this->dispatcher->addListener('response.received', function () {}); + + $this->dispatcher->clearListeners(); + + $this->assertFalse($this->dispatcher->hasListeners('request.sending')); + $this->assertFalse($this->dispatcher->hasListeners('response.received')); + } + + public function test_listener_error_does_not_stop_propagation() + { + $secondCalled = false; + + $this->dispatcher->addListener('request.sending', function () { + throw new \RuntimeException('Error in listener'); + }); + + $this->dispatcher->addListener('request.sending', function () use (&$secondCalled) { + $secondCalled = true; + }); + + $request = new Request('GET', 'https://example.com/api'); + $event = new RequestEvent($request, 'corr-123', microtime(true)); + + // Should not throw, just log + $this->dispatcher->dispatch($event); + + $this->assertTrue($secondCalled); + } + + public function test_listener_error_is_logged() + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('error') + ->with('Event listener error', $this->callback(function ($context) { + return isset($context['event']) && + isset($context['error']) && + isset($context['correlation_id']); + })); + + $dispatcher = new EventDispatcher($logger); + + $dispatcher->addListener('request.sending', function () { + throw new \RuntimeException('Error in listener'); + }); + + $request = new Request('GET', 'https://example.com/api'); + $event = new RequestEvent($request, 'corr-123', microtime(true)); + + $dispatcher->dispatch($event); + } + + public function test_remove_nonexistent_listener_does_not_error() + { + // Should not throw + $this->dispatcher->removeListener('nonexistent.event', function () {}); + $this->assertFalse($this->dispatcher->hasListeners('nonexistent.event')); + } + + public function test_listeners_are_cached_after_sorting() + { + $callback = function () {}; + $this->dispatcher->addListener('request.sending', $callback, 1); + + // First call should cache + $listeners1 = $this->dispatcher->getListeners('request.sending'); + + // Second call should return cached + $listeners2 = $this->dispatcher->getListeners('request.sending'); + + $this->assertSame($listeners1, $listeners2); + } + + public function test_cache_is_cleared_on_add() + { + $callback1 = function () {}; + $callback2 = function () {}; + + $this->dispatcher->addListener('request.sending', $callback1, 1); + $this->dispatcher->getListeners('request.sending'); // Cache + + $this->dispatcher->addListener('request.sending', $callback2, 10); + $listeners = $this->dispatcher->getListeners('request.sending'); + + // New listener should be first (higher priority) + $this->assertSame($callback2, $listeners[0]); + } +} diff --git a/tests/Unit/Events/FetchEventsTest.php b/tests/Unit/Events/FetchEventsTest.php new file mode 100644 index 0000000..100e44e --- /dev/null +++ b/tests/Unit/Events/FetchEventsTest.php @@ -0,0 +1,208 @@ + 'test']; + $options = ['timeout' => 30]; + + $event = new RequestEvent($request, $correlationId, $timestamp, $context, $options); + + $this->assertSame($request, $event->getRequest()); + $this->assertEquals($correlationId, $event->getCorrelationId()); + $this->assertEquals($timestamp, $event->getTimestamp()); + $this->assertEquals($context, $event->getContext()); + $this->assertEquals($options, $event->getOptions()); + $this->assertEquals('request.sending', $event->getName()); + } + + public function test_response_event() + { + $request = new Request('GET', 'https://example.com/api'); + $response = new Response(200, ['Content-Type' => 'application/json'], '{"success":true}'); + $correlationId = 'corr-123'; + $timestamp = microtime(true); + $duration = 0.5; + $context = ['cached' => false]; + + $event = new ResponseEvent($request, $response, $correlationId, $timestamp, $duration, $context); + + $this->assertSame($request, $event->getRequest()); + $this->assertSame($response, $event->getResponse()); + $this->assertEquals($correlationId, $event->getCorrelationId()); + $this->assertEquals($timestamp, $event->getTimestamp()); + $this->assertEquals($duration, $event->getDuration()); + $this->assertEquals(500, $event->getLatency()); // 0.5 seconds = 500ms + $this->assertEquals($context, $event->getContext()); + $this->assertEquals('response.received', $event->getName()); + } + + public function test_error_event() + { + $request = new Request('GET', 'https://example.com/api'); + $exception = new RuntimeException('Connection failed'); + $correlationId = 'corr-123'; + $timestamp = microtime(true); + $attempt = 2; + $response = new Response(500); + $context = ['retry_count' => 2]; + + $event = new ErrorEvent($request, $exception, $correlationId, $timestamp, $attempt, $response, $context); + + $this->assertSame($request, $event->getRequest()); + $this->assertSame($exception, $event->getException()); + $this->assertEquals($correlationId, $event->getCorrelationId()); + $this->assertEquals($timestamp, $event->getTimestamp()); + $this->assertEquals($attempt, $event->getAttempt()); + $this->assertSame($response, $event->getResponse()); + $this->assertEquals($context, $event->getContext()); + $this->assertEquals('error.occurred', $event->getName()); + } + + public function test_error_event_retryable_with_retryable_status() + { + $request = new Request('GET', 'https://example.com/api'); + $exception = new RuntimeException('Server error'); + $response = new Response(503); // Service Unavailable + + $event = new ErrorEvent($request, $exception, 'corr', microtime(true), 1, $response); + + $this->assertTrue($event->isRetryable()); + } + + public function test_error_event_not_retryable_with_client_error() + { + $request = new Request('GET', 'https://example.com/api'); + $exception = new RuntimeException('Not found'); + $response = new Response(404); // Not Found + + $event = new ErrorEvent($request, $exception, 'corr', microtime(true), 1, $response); + + $this->assertFalse($event->isRetryable()); + } + + public function test_error_event_retryable_without_response() + { + $request = new Request('GET', 'https://example.com/api'); + $exception = new RuntimeException('Network error'); + + $event = new ErrorEvent($request, $exception, 'corr', microtime(true), 1, null); + + // Network errors without response are retryable + $this->assertTrue($event->isRetryable()); + } + + public function test_retry_event() + { + $request = new Request('GET', 'https://example.com/api'); + $previousException = new RuntimeException('First attempt failed'); + $correlationId = 'corr-123'; + $timestamp = microtime(true); + $attempt = 2; + $maxAttempts = 3; + $delay = 1000; + $context = ['reason' => 'timeout']; + + $event = new RetryEvent( + $request, + $previousException, + $attempt, + $maxAttempts, + $delay, + $correlationId, + $timestamp, + $context + ); + + $this->assertSame($request, $event->getRequest()); + $this->assertSame($previousException, $event->getPreviousException()); + $this->assertEquals($attempt, $event->getAttempt()); + $this->assertEquals($maxAttempts, $event->getMaxAttempts()); + $this->assertEquals($delay, $event->getDelay()); + $this->assertEquals($correlationId, $event->getCorrelationId()); + $this->assertEquals($timestamp, $event->getTimestamp()); + $this->assertEquals($context, $event->getContext()); + $this->assertEquals('request.retrying', $event->getName()); + } + + public function test_retry_event_is_last_attempt() + { + $request = new Request('GET', 'https://example.com/api'); + $exception = new RuntimeException('Failed'); + + $lastAttemptEvent = new RetryEvent($request, $exception, 3, 3, 1000, 'corr', microtime(true)); + $notLastAttemptEvent = new RetryEvent($request, $exception, 2, 3, 1000, 'corr', microtime(true)); + + $this->assertTrue($lastAttemptEvent->isLastAttempt()); + $this->assertFalse($notLastAttemptEvent->isLastAttempt()); + } + + public function test_timeout_event() + { + $request = new Request('GET', 'https://example.com/api'); + $correlationId = 'corr-123'; + $timestamp = microtime(true); + $timeout = 30; + $elapsed = 31.5; + $context = ['operation' => 'api_call']; + + $event = new TimeoutEvent($request, $timeout, $elapsed, $correlationId, $timestamp, $context); + + $this->assertSame($request, $event->getRequest()); + $this->assertEquals($timeout, $event->getTimeout()); + $this->assertEquals($elapsed, $event->getElapsed()); + $this->assertEquals($correlationId, $event->getCorrelationId()); + $this->assertEquals($timestamp, $event->getTimestamp()); + $this->assertEquals($context, $event->getContext()); + $this->assertEquals('request.timeout', $event->getName()); + } + + public function test_redirect_event() + { + $request = new Request('GET', 'https://example.com/old-path'); + $response = new Response(301, ['Location' => 'https://example.com/new-path']); + $correlationId = 'corr-123'; + $timestamp = microtime(true); + $location = 'https://example.com/new-path'; + $redirectCount = 2; + $context = ['reason' => 'permanent']; + + $event = new RedirectEvent( + $request, + $response, + $location, + $redirectCount, + $correlationId, + $timestamp, + $context + ); + + $this->assertSame($request, $event->getRequest()); + $this->assertSame($response, $event->getResponse()); + $this->assertEquals($location, $event->getLocation()); + $this->assertEquals($redirectCount, $event->getRedirectCount()); + $this->assertEquals($correlationId, $event->getCorrelationId()); + $this->assertEquals($timestamp, $event->getTimestamp()); + $this->assertEquals($context, $event->getContext()); + $this->assertEquals('request.redirecting', $event->getName()); + } +} diff --git a/tests/Unit/ManagesDebugAndProfilingTest.php b/tests/Unit/ManagesDebugAndProfilingTest.php index 22452a2..3cca57a 100644 --- a/tests/Unit/ManagesDebugAndProfilingTest.php +++ b/tests/Unit/ManagesDebugAndProfilingTest.php @@ -3,7 +3,6 @@ namespace Tests\Unit; use Fetch\Http\ClientHandler; -use Fetch\Support\DebugInfo; use Fetch\Support\FetchProfiler; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/SupportsHooksTest.php b/tests/Unit/SupportsHooksTest.php new file mode 100644 index 0000000..346f8ba --- /dev/null +++ b/tests/Unit/SupportsHooksTest.php @@ -0,0 +1,273 @@ +handler = new class + { + use SupportsHooks; + + public ?LoggerInterface $logger = null; + }; + } + + public function test_on_request_registers_listener() + { + $callback = function () {}; + + $result = $this->handler->onRequest($callback); + + $this->assertSame($this->handler, $result); + $this->assertTrue($this->handler->hasHooks('request.sending')); + } + + public function test_on_response_registers_listener() + { + $callback = function () {}; + + $this->handler->onResponse($callback); + + $this->assertTrue($this->handler->hasHooks('response.received')); + } + + public function test_on_error_registers_listener() + { + $callback = function () {}; + + $this->handler->onError($callback); + + $this->assertTrue($this->handler->hasHooks('error.occurred')); + } + + public function test_on_retry_registers_listener() + { + $callback = function () {}; + + $this->handler->onRetry($callback); + + $this->assertTrue($this->handler->hasHooks('request.retrying')); + } + + public function test_on_timeout_registers_listener() + { + $callback = function () {}; + + $this->handler->onTimeout($callback); + + $this->assertTrue($this->handler->hasHooks('request.timeout')); + } + + public function test_on_redirect_registers_listener() + { + $callback = function () {}; + + $this->handler->onRedirect($callback); + + $this->assertTrue($this->handler->hasHooks('request.redirecting')); + } + + public function test_when_registers_listener() + { + $callback = function () {}; + + $this->handler->when('custom.event', $callback); + + $this->assertTrue($this->handler->hasHooks('custom.event')); + } + + public function test_hooks_registers_multiple_listeners() + { + $this->handler->hooks([ + 'before_send' => function () {}, + 'after_response' => function () {}, + 'on_error' => function () {}, + ]); + + $this->assertTrue($this->handler->hasHooks('request.sending')); + $this->assertTrue($this->handler->hasHooks('response.received')); + $this->assertTrue($this->handler->hasHooks('error.occurred')); + } + + public function test_hooks_normalizes_shorthand_names() + { + $this->handler->hooks([ + 'before_send' => function () {}, + 'after_response' => function () {}, + 'on_error' => function () {}, + 'on_retry' => function () {}, + 'on_timeout' => function () {}, + 'on_redirect' => function () {}, + ]); + + $this->assertTrue($this->handler->hasHooks('request.sending')); + $this->assertTrue($this->handler->hasHooks('response.received')); + $this->assertTrue($this->handler->hasHooks('error.occurred')); + $this->assertTrue($this->handler->hasHooks('request.retrying')); + $this->assertTrue($this->handler->hasHooks('request.timeout')); + $this->assertTrue($this->handler->hasHooks('request.redirecting')); + } + + public function test_hooks_accepts_full_event_names() + { + $this->handler->hooks([ + 'request.sending' => function () {}, + 'response.received' => function () {}, + ]); + + $this->assertTrue($this->handler->hasHooks('request.sending')); + $this->assertTrue($this->handler->hasHooks('response.received')); + } + + public function test_has_hooks_returns_false_when_none_registered() + { + $this->assertFalse($this->handler->hasHooks()); + $this->assertFalse($this->handler->hasHooks('request.sending')); + } + + public function test_has_hooks_returns_true_when_any_registered() + { + $this->handler->onRequest(function () {}); + + $this->assertTrue($this->handler->hasHooks()); + } + + public function test_clear_hooks_clears_specific_event() + { + $this->handler->onRequest(function () {}); + $this->handler->onResponse(function () {}); + + $this->handler->clearHooks('request.sending'); + + $this->assertFalse($this->handler->hasHooks('request.sending')); + $this->assertTrue($this->handler->hasHooks('response.received')); + } + + public function test_clear_hooks_clears_all() + { + $this->handler->onRequest(function () {}); + $this->handler->onResponse(function () {}); + + $this->handler->clearHooks(); + + $this->assertFalse($this->handler->hasHooks()); + } + + public function test_get_event_dispatcher_creates_default() + { + $dispatcher = $this->handler->getEventDispatcher(); + + $this->assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + } + + public function test_set_event_dispatcher() + { + $customDispatcher = new EventDispatcher; + + $result = $this->handler->setEventDispatcher($customDispatcher); + + $this->assertSame($this->handler, $result); + $this->assertSame($customDispatcher, $this->handler->getEventDispatcher()); + } + + public function test_listeners_are_called_with_priority() + { + $order = []; + + $this->handler->onRequest(function () use (&$order) { + $order[] = 'low'; + }, 1); + + $this->handler->onRequest(function () use (&$order) { + $order[] = 'high'; + }, 10); + + $request = new Request('GET', 'https://example.com/api'); + $event = new RequestEvent($request, 'corr-123', microtime(true)); + + // Call dispatchEvent through reflection since it's protected + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('dispatchEvent'); + $method->setAccessible(true); + $method->invoke($this->handler, $event); + + $this->assertEquals(['high', 'low'], $order); + } + + public function test_dispatch_event_does_nothing_without_dispatcher() + { + // Create a fresh handler without dispatcher + $handler = new class + { + use SupportsHooks; + + public ?LoggerInterface $logger = null; + }; + + // Verify no dispatcher is set initially + $reflection = new \ReflectionClass($handler); + $property = $reflection->getProperty('eventDispatcher'); + $property->setAccessible(true); + + $this->assertNull($property->getValue($handler)); + + // dispatchEvent should not throw when no dispatcher + $request = new Request('GET', 'https://example.com/api'); + $event = new RequestEvent($request, 'corr-123', microtime(true)); + + $method = $reflection->getMethod('dispatchEvent'); + $method->setAccessible(true); + $method->invoke($handler, $event); + + // No exception means success + $this->assertTrue(true); + } + + public function test_generate_correlation_id() + { + $reflection = new \ReflectionClass($this->handler); + $method = $reflection->getMethod('generateCorrelationId'); + $method->setAccessible(true); + + $id1 = $method->invoke($this->handler); + $id2 = $method->invoke($this->handler); + + $this->assertNotEmpty($id1); + $this->assertNotEmpty($id2); + $this->assertNotEquals($id1, $id2); + $this->assertEquals(32, strlen($id1)); // 16 bytes = 32 hex chars + } + + public function test_fluent_interface_for_all_on_methods() + { + $result = $this->handler + ->onRequest(function () {}) + ->onResponse(function () {}) + ->onError(function () {}) + ->onRetry(function () {}) + ->onTimeout(function () {}) + ->onRedirect(function () {}) + ->when('custom.event', function () {}) + ->hooks(['before_send' => function () {}]) + ->clearHooks('custom.event'); + + $this->assertSame($this->handler, $result); + } +}