Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Attributes;
use Attribute;
/**
* Attribute to configure webhook authentication
* Supports various authentication strategies for webhook endpoints
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class WebhookAuth
{
/** @param string[] $allowedIps */
public function __construct(
public string $strategy = 'signature',
public ?string $header = null,
public ?string $token = null,
public array $allowedIps = [],
public bool $required = true
) {
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Attributes;
use Attribute;
/**
* Attribute to mark a controller method as a webhook endpoint
* Enables automatic webhook processing with provider-specific handling
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class WebhookEndpoint
{
/** @param string[] $events */
public function __construct(
public string $provider,
public array $events = [],
public bool $async = true,
public int $timeout = 30,
public bool $idempotent = true
) {
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Attributes;
use Attribute;
/**
* Attribute to configure webhook signature verification
* Supports multiple signature algorithms and header formats
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class WebhookSignature
{
public function __construct(
public string $algorithm = 'sha256',
public string $header = 'X-Signature',
public ?string $secretKey = null,
public string $encoding = 'hex',
public string $prefix = ''
) {
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Events;
use App\Framework\Events\DomainEvent;
use App\Framework\Webhook\ValueObjects\WebhookType;
use DateTimeImmutable;
use Throwable;
/**
* Domain event fired when a webhook processing or sending fails
* Enables error tracking, alerting, and retry mechanisms
*/
final readonly class WebhookFailed implements DomainEvent
{
public function __construct(
public WebhookType $type,
public string $reason,
public ?string $url = null,
public ?string $provider = null,
public ?string $webhookId = null,
public ?array $payload = null,
public ?int $responseCode = null,
public ?int $attemptNumber = null,
public DateTimeImmutable $failedAt = new DateTimeImmutable(),
public ?string $errorClass = null,
public ?string $stackTrace = null
) {
}
public static function fromException(
Throwable $exception,
WebhookType $type,
?string $url = null,
?string $provider = null,
?int $attemptNumber = null
): self {
return new self(
type: $type,
reason: $exception->getMessage(),
url: $url,
provider: $provider,
failedAt: new DateTimeImmutable(),
errorClass: get_class($exception),
stackTrace: $exception->getTraceAsString(),
attemptNumber: $attemptNumber
);
}
public static function fromResponse(
string $url,
int $responseCode,
string $responseBody = '',
?string $provider = null,
?int $attemptNumber = null
): self {
return new self(
type: WebhookType::OUTGOING,
reason: "HTTP {$responseCode}: " . substr($responseBody, 0, 200),
url: $url,
provider: $provider,
responseCode: $responseCode,
attemptNumber: $attemptNumber
);
}
public function getEventName(): string
{
return 'webhook.failed';
}
public function getAggregateId(): string
{
return $this->webhookId ?? ($this->url ? md5($this->url) : 'unknown');
}
public function isRetryable(): bool
{
// Network/timeout errors are typically retryable
if ($this->responseCode === null) {
return true;
}
// HTTP 5xx errors are retryable, 4xx usually are not
return $this->responseCode >= 500;
}
public function toArray(): array
{
return [
'type' => $this->type->value,
'reason' => $this->reason,
'url' => $this->url,
'provider' => $this->provider,
'webhook_id' => $this->webhookId,
'response_code' => $this->responseCode,
'attempt_number' => $this->attemptNumber,
'failed_at' => $this->failedAt->format('c'),
'error_class' => $this->errorClass,
'is_retryable' => $this->isRetryable(),
];
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Events;
use App\Framework\Events\DomainEvent;
use App\Framework\Webhook\ValueObjects\WebhookPayload;
use App\Framework\Webhook\ValueObjects\WebhookProvider;
use DateTimeImmutable;
/**
* Domain event fired when a webhook is successfully received and verified
* Enables decoupled processing of webhook events across the application
*/
final readonly class WebhookReceived implements DomainEvent
{
public function __construct(
public WebhookProvider $provider,
public WebhookPayload $payload,
public string $endpoint,
public DateTimeImmutable $receivedAt,
public ?string $eventType = null,
public ?string $webhookId = null
) {
}
public static function create(
WebhookProvider $provider,
WebhookPayload $payload,
string $endpoint,
?string $eventType = null
): self {
return new self(
provider: $provider,
payload: $payload,
endpoint: $endpoint,
receivedAt: new DateTimeImmutable(),
eventType: $eventType ?? $payload->getEventType(),
webhookId: $payload->getWebhookId()
);
}
public function getEventName(): string
{
return 'webhook.received';
}
public function getAggregateId(): string
{
return $this->webhookId ?? 'unknown';
}
public function toArray(): array
{
return [
'provider' => $this->provider->toString(),
'endpoint' => $this->endpoint,
'event_type' => $this->eventType,
'webhook_id' => $this->webhookId,
'received_at' => $this->receivedAt->format('c'),
'payload_size' => strlen($this->payload->rawBody),
];
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Events;
use App\Framework\Events\DomainEvent;
use DateTimeImmutable;
/**
* Domain event fired when a webhook is successfully sent
* Tracks outgoing webhook delivery for monitoring and debugging
*/
final readonly class WebhookSent implements DomainEvent
{
public function __construct(
public string $url,
public array $payload,
public int $responseCode,
public DateTimeImmutable $sentAt,
public ?string $provider = null,
public ?string $webhookId = null,
public ?float $duration = null
) {
}
public static function create(
string $url,
array $payload,
int $responseCode,
?string $provider = null,
?float $duration = null
): self {
return new self(
url: $url,
payload: $payload,
responseCode: $responseCode,
sentAt: new DateTimeImmutable(),
provider: $provider,
webhookId: $payload['webhook_id'] ?? null,
duration: $duration
);
}
public function getEventName(): string
{
return 'webhook.sent';
}
public function getAggregateId(): string
{
return $this->webhookId ?? md5($this->url);
}
public function wasSuccessful(): bool
{
return $this->responseCode >= 200 && $this->responseCode < 300;
}
public function toArray(): array
{
return [
'url' => $this->url,
'provider' => $this->provider,
'response_code' => $this->responseCode,
'webhook_id' => $this->webhookId,
'sent_at' => $this->sentAt->format('c'),
'duration_ms' => $this->duration ? round($this->duration * 1000, 2) : null,
'payload_size' => strlen(json_encode($this->payload)),
'successful' => $this->wasSuccessful(),
];
}
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Jobs;
use App\Framework\Logging\Logger;
use App\Framework\Webhook\Sending\WebhookSender;
use App\Framework\Webhook\ValueObjects\WebhookPayload;
use App\Framework\Webhook\ValueObjects\WebhookProvider;
use DateTimeImmutable;
/**
* Background job for sending webhooks asynchronously
* Integrates with framework's Queue system for reliable delivery
*/
final readonly class WebhookJob
{
public function __construct(
public string $url,
public WebhookPayload $payload,
public WebhookProvider $provider,
public string $secret,
public int $maxRetries = 3,
public int $priority = 0,
public ?DateTimeImmutable $scheduledAt = null,
public array $options = []
) {
}
/**
* Create webhook job for immediate execution
*/
public static function immediate(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
array $options = []
): self {
return new self(
url: $url,
payload: $payload,
provider: $provider,
secret: $secret,
maxRetries: $options['max_retries'] ?? 3,
priority: $options['priority'] ?? 0,
scheduledAt: null,
options: $options
);
}
/**
* Create webhook job for delayed execution
*/
public static function delayed(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
DateTimeImmutable $scheduledAt,
array $options = []
): self {
return new self(
url: $url,
payload: $payload,
provider: $provider,
secret: $secret,
maxRetries: $options['max_retries'] ?? 3,
priority: $options['priority'] ?? 0,
scheduledAt: $scheduledAt,
options: $options
);
}
/**
* Execute the webhook job
* This method is called by the queue worker
*/
public function execute(WebhookSender $sender, Logger $logger): void
{
$logger->info('Executing webhook job', [
'url' => $this->url,
'provider' => $this->provider->toString(),
'max_retries' => $this->maxRetries,
'scheduled_at' => $this->scheduledAt?->format('c'),
]);
// Check if job should be delayed
if ($this->scheduledAt !== null && $this->scheduledAt > new DateTimeImmutable()) {
$logger->debug('Webhook job scheduled for future execution', [
'url' => $this->url,
'scheduled_at' => $this->scheduledAt->format('c'),
]);
// Re-queue the job for later execution
// This would need integration with the queue system's delay functionality
throw new \RuntimeException('Job scheduled for future execution');
}
// Send webhook with retry logic
$result = $sender->sendWithRetry(
url: $this->url,
payload: $this->payload,
provider: $this->provider,
secret: $this->secret,
maxRetries: $this->maxRetries,
options: $this->options
);
if ($result->isFailed()) {
$logger->error('Webhook job failed after all retries', [
'webhook_id' => $result->webhookId,
'url' => $this->url,
'provider' => $this->provider->toString(),
'final_status' => $result->statusCode,
'attempts' => $result->attempt,
'error' => $result->error,
]);
// Throw exception to mark job as failed in queue
throw new \RuntimeException(
"Webhook failed after {$result->attempt} attempts: {$result->error}"
);
}
$logger->info('Webhook job completed successfully', [
'webhook_id' => $result->webhookId,
'url' => $this->url,
'provider' => $this->provider->toString(),
'status_code' => $result->statusCode,
'attempts' => $result->attempt,
]);
}
/**
* Get job priority for queue ordering
*/
public function getPriority(): int
{
return $this->priority;
}
/**
* Check if job should be executed now
*/
public function shouldExecuteNow(): bool
{
if ($this->scheduledAt === null) {
return true;
}
return $this->scheduledAt <= new DateTimeImmutable();
}
/**
* Get job identifier for tracking
*/
public function getJobId(): string
{
return 'webhook_' . md5($this->url . $this->payload->rawBody . $this->provider->toString());
}
/**
* Serialize job for queue storage
*/
public function toArray(): array
{
return [
'type' => 'webhook',
'url' => $this->url,
'payload' => $this->payload->toArray(),
'provider' => $this->provider->toString(),
'secret' => $this->secret, // Note: Consider encrypting secrets in queue
'max_retries' => $this->maxRetries,
'priority' => $this->priority,
'scheduled_at' => $this->scheduledAt?->format('c'),
'options' => $this->options,
'created_at' => (new DateTimeImmutable())->format('c'),
];
}
/**
* Deserialize job from queue data
*/
public static function fromArray(array $data): self
{
return new self(
url: $data['url'],
payload: WebhookPayload::fromArray($data['payload']),
provider: WebhookProvider::fromString($data['provider']),
secret: $data['secret'],
maxRetries: $data['max_retries'] ?? 3,
priority: $data['priority'] ?? 0,
scheduledAt: isset($data['scheduled_at'])
? new DateTimeImmutable($data['scheduled_at'])
: null,
options: $data['options'] ?? []
);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Middleware;
use App\Framework\Core\Environment;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\JsonResponse;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Http\Response;
use App\Framework\Logging\Logger;
use App\Framework\Webhook\Attributes\WebhookEndpoint;
use App\Framework\Webhook\Processing\WebhookRequestHandler;
use App\Framework\Webhook\ValueObjects\WebhookProvider;
use ReflectionMethod;
/**
* Automatic webhook processing middleware
* Intercepts routes marked with WebhookEndpoint attribute for automatic handling
*/
final readonly class WebhookMiddleware implements HttpMiddleware
{
public function __construct(
private WebhookRequestHandler $webhookHandler,
private Environment $environment,
private Logger $logger
) {
}
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
$request = $context->request;
// Check if route has WebhookEndpoint attribute
$webhookAttribute = $this->extractWebhookAttribute($stateManager);
if ($webhookAttribute === null) {
// No webhook attribute - continue normally
return $next($context);
}
// Get webhook configuration from environment
$webhookConfig = $this->getWebhookConfig($webhookAttribute->provider);
if ($webhookConfig === null) {
$this->logger->warning('Webhook configuration not found', [
'provider' => $webhookAttribute->provider,
'endpoint' => $request->getPath(),
]);
// Return error response
$errorResponse = new JsonResponse([
'status' => 'error',
'message' => 'Webhook configuration not found',
'provider' => $webhookAttribute->provider,
], 500);
return $context->withResponse($errorResponse);
}
// Process webhook using WebhookRequestHandler
try {
$provider = WebhookProvider::fromString($webhookAttribute->provider);
$webhookResponse = $this->webhookHandler->handle(
request: $request,
provider: $provider,
secret: $webhookConfig['secret'],
allowedEvents: $webhookAttribute->events
);
// Store webhook processing result in request state
$stateManager->set('webhook.processed', true);
$stateManager->set('webhook.provider', $webhookAttribute->provider);
$stateManager->set('webhook.async', $webhookAttribute->async);
// Return webhook processing response
return $context->withResponse($webhookResponse);
} catch (\Exception $e) {
$this->logger->error('Webhook middleware processing failed', [
'provider' => $webhookAttribute->provider,
'endpoint' => $request->getPath(),
'error' => $e->getMessage(),
]);
// Store error in request state
$stateManager->set('webhook.error', $e->getMessage());
// Return error response
$errorResponse = new JsonResponse([
'status' => 'error',
'message' => 'Webhook processing failed',
'provider' => $webhookAttribute->provider,
'error' => $e->getMessage(),
], 500);
return $context->withResponse($errorResponse);
}
}
/**
* Extract WebhookEndpoint attribute from request state or route info
*/
private function extractWebhookAttribute(RequestStateManager $stateManager): ?WebhookEndpoint
{
// Try to get route info from request state
$routeInfo = $stateManager->get('_route_info');
if (! $routeInfo || ! isset($routeInfo['controller'], $routeInfo['method'])) {
return null;
}
try {
$reflection = new ReflectionMethod($routeInfo['controller'], $routeInfo['method']);
$attributes = $reflection->getAttributes(WebhookEndpoint::class);
if (empty($attributes)) {
return null;
}
return $attributes[0]->newInstance();
} catch (\ReflectionException $e) {
$this->logger->debug('Could not extract webhook attribute', [
'controller' => $routeInfo['controller'],
'method' => $routeInfo['method'],
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Get webhook configuration from environment for provider
*/
private function getWebhookConfig(string $provider): ?array
{
$envKey = strtoupper("WEBHOOK_{$provider}_SECRET");
$secret = $this->environment->get($envKey);
if (empty($secret)) {
return null;
}
return [
'secret' => $secret,
'provider' => $provider,
];
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Processing;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Webhook\ValueObjects\WebhookProvider;
use DateTimeImmutable;
/**
* Prevents duplicate webhook processing using cache-based idempotency
* Uses framework's Cache system for persistence and expiration
*/
final readonly class IdempotencyService
{
private const DEFAULT_TTL_HOURS = 24;
private const STATUS_PROCESSING = 'processing';
private const STATUS_PROCESSED = 'processed';
private const STATUS_FAILED = 'failed';
public function __construct(
private Cache $cache,
private int $ttlHours = self::DEFAULT_TTL_HOURS
) {
}
/**
* Check if webhook has already been processed or is currently processing
*/
public function isDuplicate(string $webhookId, WebhookProvider $provider): bool
{
$cacheKey = $this->createCacheKey($webhookId, $provider);
$cacheItem = $this->cache->get($cacheKey);
return $cacheItem !== null;
}
/**
* Mark webhook as currently being processed
* Prevents concurrent processing of the same webhook
*/
public function markProcessing(string $webhookId, WebhookProvider $provider): void
{
$cacheKey = $this->createCacheKey($webhookId, $provider);
$data = $this->createIdempotencyData(self::STATUS_PROCESSING);
$cacheItem = CacheItem::forSetting(
key: $cacheKey,
value: $data,
ttl: Duration::fromHours($this->ttlHours)
);
$this->cache->set($cacheItem);
}
/**
* Mark webhook as successfully processed
*/
public function markProcessed(string $webhookId, WebhookProvider $provider, ?array $result = null): void
{
$cacheKey = $this->createCacheKey($webhookId, $provider);
$data = $this->createIdempotencyData(self::STATUS_PROCESSED, null, $result);
$cacheItem = CacheItem::forSetting(
key: $cacheKey,
value: $data,
ttl: Duration::fromHours($this->ttlHours)
);
$this->cache->set($cacheItem);
}
/**
* Mark webhook processing as failed
*/
public function markFailed(string $webhookId, WebhookProvider $provider, string $error): void
{
$cacheKey = $this->createCacheKey($webhookId, $provider);
$data = $this->createIdempotencyData(self::STATUS_FAILED, $error);
$cacheItem = CacheItem::forSetting(
key: $cacheKey,
value: $data,
ttl: Duration::fromHours($this->ttlHours)
);
$this->cache->set($cacheItem);
}
/**
* Get processing status of webhook
*/
public function getStatus(string $webhookId, WebhookProvider $provider): ?array
{
$cacheKey = $this->createCacheKey($webhookId, $provider);
$cacheItem = $this->cache->get($cacheKey);
return $cacheItem?->value;
}
/**
* Check if webhook is currently being processed
*/
public function isProcessing(string $webhookId, WebhookProvider $provider): bool
{
$status = $this->getStatus($webhookId, $provider);
return $status && $status['status'] === self::STATUS_PROCESSING;
}
/**
* Check if webhook was successfully processed
*/
public function wasProcessed(string $webhookId, WebhookProvider $provider): bool
{
$status = $this->getStatus($webhookId, $provider);
return $status && $status['status'] === self::STATUS_PROCESSED;
}
/**
* Check if webhook processing failed
*/
public function hasFailed(string $webhookId, WebhookProvider $provider): bool
{
$status = $this->getStatus($webhookId, $provider);
return $status && $status['status'] === self::STATUS_FAILED;
}
/**
* Remove idempotency record manually (for reprocessing)
*/
public function reset(string $webhookId, WebhookProvider $provider): bool
{
$cacheKey = $this->createCacheKey($webhookId, $provider);
return $this->cache->forget($cacheKey);
}
/**
* Get all webhook processing records for a provider (useful for debugging)
*/
public function getProviderStatus(WebhookProvider $provider): array
{
// This would require cache tag support or cache iteration
// For now, individual webhook status is the primary interface
return [];
}
/**
* Create cache key for webhook idempotency
*/
private function createCacheKey(string $webhookId, WebhookProvider $provider): CacheKey
{
$keyString = "webhook_idempotency:{$provider->toString()}:{$webhookId}";
return CacheKey::fromString($keyString);
}
/**
* Create idempotency data structure
*/
private function createIdempotencyData(
string $status,
?string $error = null,
?array $result = null
): array {
$data = [
'status' => $status,
'timestamp' => (new DateTimeImmutable())->format('c'),
'ttl_hours' => $this->ttlHours,
];
if ($error !== null) {
$data['error'] = $error;
}
if ($result !== null) {
$data['result'] = $result;
}
return $data;
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Processing;
use App\Framework\Exception\WebhookException;
use App\Framework\Logging\Logger;
use App\Framework\Webhook\Jobs\WebhookJob;
use App\Framework\Webhook\Sending\WebhookSender;
/**
* Processes webhook jobs from the queue
* Integrates with framework's queue worker system
*/
final readonly class WebhookJobProcessor
{
public function __construct(
private WebhookSender $webhookSender,
private Logger $logger
) {
}
/**
* Process webhook job from queue
* Called by queue worker when job is dequeued
*/
public function process(object $job): void
{
if (! $job instanceof WebhookJob) {
throw new WebhookException('Invalid job type for webhook processor');
}
$this->logger->debug('Processing webhook job', [
'job_id' => $job->getJobId(),
'url' => $job->url,
'provider' => $job->provider->toString(),
]);
try {
// Execute the webhook job
$job->execute($this->webhookSender, $this->logger);
} catch (\Exception $e) {
$this->logger->error('Webhook job processing failed', [
'job_id' => $job->getJobId(),
'url' => $job->url,
'provider' => $job->provider->toString(),
'error' => $e->getMessage(),
]);
// Re-throw to let queue handle failure (retry/dead letter)
throw $e;
}
}
/**
* Check if processor can handle the job type
*/
public function canProcess(object $job): bool
{
return $job instanceof WebhookJob;
}
/**
* Get processor name for queue configuration
*/
public function getName(): string
{
return 'webhook';
}
/**
* Get processor priority for job routing
*/
public function getPriority(): int
{
return 0; // Normal priority
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Processing;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\JsonResult;
use App\Framework\Logging\Logger;
use App\Framework\Webhook\Events\WebhookFailed;
use App\Framework\Webhook\Events\WebhookReceived;
use App\Framework\Webhook\Security\SignatureVerifier;
use App\Framework\Webhook\ValueObjects\WebhookPayload;
use App\Framework\Webhook\ValueObjects\WebhookProvider;
use Exception;
/**
* Central webhook request handler using EventDispatcher
* Handles incoming webhook requests, verifies signatures, and dispatches events
*/
final readonly class WebhookRequestHandler
{
public function __construct(
private SignatureVerifier $signatureVerifier,
private EventDispatcher $eventDispatcher,
private IdempotencyService $idempotencyService,
private Logger $logger
) {
}
/**
* Process incoming webhook request
* Returns JSON response with processing status
*/
public function handle(
HttpRequest $request,
WebhookProvider $provider,
string $secret,
array $allowedEvents = []
): HttpResponse {
$requestId = $this->generateRequestId();
$rawPayload = $request->getRawBody();
try {
// Step 1: Verify signature
if (! $this->verifySignature($request, $rawPayload, $provider, $secret)) {
$this->logger->warning('Webhook signature verification failed', [
'provider' => $provider->toString(),
'request_id' => $requestId,
'ip' => $request->server->getClientIp(),
'user_agent' => $request->server->getUserAgent()?->toString(),
]);
return $this->errorResponse('Invalid signature', 401, $requestId);
}
// Step 2: Parse payload
$payload = WebhookPayload::fromRaw($rawPayload);
$eventType = $payload->getEventType();
// Step 3: Check allowed events
if (! empty($allowedEvents) && ! in_array($eventType, $allowedEvents, true)) {
$this->logger->info('Webhook event not allowed', [
'provider' => $provider->toString(),
'event_type' => $eventType,
'allowed_events' => $allowedEvents,
'request_id' => $requestId,
]);
return $this->successResponse('Event ignored', $requestId);
}
// Step 4: Check idempotency
$webhookId = $payload->getWebhookId();
if ($webhookId && $this->idempotencyService->isDuplicate($webhookId, $provider)) {
$this->logger->info('Duplicate webhook request detected', [
'webhook_id' => $webhookId,
'provider' => $provider->toString(),
'request_id' => $requestId,
]);
return $this->successResponse('Webhook already processed', $requestId);
}
// Step 5: Mark as processing
if ($webhookId) {
$this->idempotencyService->markProcessing($webhookId, $provider);
}
// Step 6: Create and dispatch webhook received event
$webhookEvent = WebhookReceived::create(
provider: $provider,
payload: $payload,
endpoint: $request->getPath(),
eventType: $eventType
);
// Dispatch using EventDispatcher - returns array of handler results
$results = $this->eventDispatcher->dispatch($webhookEvent);
// Step 7: Mark as processed
if ($webhookId) {
$this->idempotencyService->markProcessed($webhookId, $provider);
}
$this->logger->info('Webhook processed successfully', [
'provider' => $provider->toString(),
'event_type' => $eventType,
'webhook_id' => $webhookId,
'request_id' => $requestId,
'handlers_executed' => count($results),
'processing_time' => microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'],
]);
return $this->successResponse('Webhook processed', $requestId, [
'event_type' => $eventType,
'webhook_id' => $webhookId,
'handlers_executed' => count($results),
]);
} catch (Exception $e) {
// Step 8: Handle processing errors
if (isset($webhookId)) {
$this->idempotencyService->markFailed($webhookId, $provider, $e->getMessage());
}
// Dispatch webhook failed event
$failedEvent = WebhookFailed::create(
provider: $provider,
endpoint: $request->getPath(),
error: $e->getMessage(),
payload: isset($payload) ? $payload : null
);
$this->eventDispatcher->dispatch($failedEvent);
$this->logger->error('Webhook processing failed', [
'provider' => $provider->toString(),
'request_id' => $requestId,
'error' => $e->getMessage(),
'webhook_id' => $webhookId ?? null,
]);
return $this->errorResponse('Webhook processing failed', 500, $requestId);
}
}
/**
* Verify webhook signature using provider-specific verification
*/
private function verifySignature(
HttpRequest $request,
string $payload,
WebhookProvider $provider,
string $secret
): bool {
$signatureHeader = $this->signatureVerifier->getSignatureHeader($provider);
$signature = $request->headers->get($signatureHeader);
if (empty($signature)) {
return false;
}
return $this->signatureVerifier->verify($payload, $signature, $secret, $provider);
}
/**
* Generate unique request ID for tracking
*/
private function generateRequestId(): string
{
return 'wh_' . bin2hex(random_bytes(8)) . '_' . time();
}
/**
* Create success response
*/
private function successResponse(string $message, string $requestId, array $data = []): JsonResult
{
return new JsonResult([
'status' => 'success',
'message' => $message,
'request_id' => $requestId,
'timestamp' => date('c'),
...$data,
]);
}
/**
* Create error response
*/
private function errorResponse(string $message, int $statusCode, string $requestId): JsonResult
{
return new JsonResult([
'status' => 'error',
'message' => $message,
'request_id' => $requestId,
'timestamp' => date('c'),
], $statusCode);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Security\Providers;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Encryption\HmacService;
use App\Framework\Webhook\ValueObjects\WebhookSignature;
/**
* Generic HMAC signature provider using framework's Hash system
* Supports standard HMAC verification with configurable algorithms
*/
final readonly class GenericHmacProvider implements SignatureProvider
{
public function __construct(
private HmacService $hmacService,
private HashAlgorithm $algorithm = HashAlgorithm::SHA256
) {
}
public function verify(string $payload, string $signatureHeader, string $secret): bool
{
$signature = $this->parseSignature($signatureHeader);
return $this->hmacService->verifyHmacString(
$payload,
$signature->toString(),
$secret,
$this->algorithm
);
}
public function parseSignature(string $headerValue): WebhookSignature
{
return WebhookSignature::fromHeader($headerValue);
}
public function generateSignature(string $payload, string $secret): string
{
$hash = $this->hmacService->generateHmac($payload, $secret, $this->algorithm);
return $this->algorithm->value . '=' . $hash->toString();
}
public function getSignatureHeader(): string
{
return 'X-Signature';
}
public function getProviderName(): string
{
return 'generic';
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Security\Providers;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Encryption\HmacService;
use App\Framework\Webhook\ValueObjects\WebhookSignature;
use InvalidArgumentException;
/**
* GitHub-specific signature provider
* Handles GitHub's webhook signature format with sha256 prefix
*/
final readonly class GitHubSignatureProvider implements SignatureProvider
{
public function __construct(
private HmacService $hmacService
) {
}
public function verify(string $payload, string $signatureHeader, string $secret): bool
{
if (! str_starts_with($signatureHeader, 'sha256=')) {
return false;
}
$signature = substr($signatureHeader, 7);
return $this->hmacService->verifyHmacString(
$payload,
$signature,
$secret,
HashAlgorithm::SHA256
);
}
public function parseSignature(string $headerValue): WebhookSignature
{
if (! str_starts_with($headerValue, 'sha256=')) {
throw new InvalidArgumentException('Invalid GitHub signature format: must start with sha256=');
}
$signature = substr($headerValue, 7);
return WebhookSignature::create($signature, 'sha256');
}
public function generateSignature(string $payload, string $secret): string
{
$hash = $this->hmacService->generateHmac($payload, $secret, HashAlgorithm::SHA256);
return 'sha256=' . $hash->toString();
}
public function getSignatureHeader(): string
{
return 'X-Hub-Signature-256';
}
public function getProviderName(): string
{
return 'github';
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Security\Providers;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Encryption\HmacService;
use App\Framework\Webhook\ValueObjects\WebhookSignature;
/**
* Legal service provider for impressum/privacy content updates
* Simple HMAC-based signature verification for legal content webhooks
*/
final readonly class LegalServiceProvider implements SignatureProvider
{
public function __construct(
private HmacService $hmacService
) {
}
public function verify(string $payload, string $signatureHeader, string $secret): bool
{
$signature = $this->parseSignature($signatureHeader);
return $this->hmacService->verifyHmacString(
$payload,
$signature->toString(),
$secret,
HashAlgorithm::SHA256
);
}
public function parseSignature(string $headerValue): WebhookSignature
{
// Support both prefixed and raw signature formats
if (str_contains($headerValue, '=')) {
[$algorithm, $signature] = explode('=', $headerValue, 2);
return WebhookSignature::create($signature, $algorithm);
}
return WebhookSignature::create($headerValue, 'sha256');
}
public function generateSignature(string $payload, string $secret): string
{
$hash = $this->hmacService->generateHmac($payload, $secret, HashAlgorithm::SHA256);
return 'sha256=' . $hash->toString();
}
public function getSignatureHeader(): string
{
return 'X-Legal-Signature';
}
public function getProviderName(): string
{
return 'legal-service';
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Security\Providers;
use App\Framework\Webhook\ValueObjects\WebhookSignature;
/**
* Interface for provider-specific webhook signature handling
* Enables support for different signature formats and verification methods
*/
interface SignatureProvider
{
/**
* Verify a webhook signature against the payload
*/
public function verify(string $payload, string $signatureHeader, string $secret): bool;
/**
* Parse signature from HTTP header value
*/
public function parseSignature(string $headerValue): WebhookSignature;
/**
* Generate signature for outgoing webhooks
*/
public function generateSignature(string $payload, string $secret): string;
/**
* Get the expected header name for this provider
*/
public function getSignatureHeader(): string;
/**
* Get provider name
*/
public function getProviderName(): string;
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Security\Providers;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Encryption\HmacService;
use App\Framework\Webhook\ValueObjects\WebhookSignature;
use InvalidArgumentException;
/**
* Stripe-specific signature provider
* Handles Stripe's webhook signature format with timestamp validation
*/
final readonly class StripeSignatureProvider implements SignatureProvider
{
private const TOLERANCE_SECONDS = 300; // 5 minutes
public function __construct(
private HmacService $hmacService,
private int $tolerance = self::TOLERANCE_SECONDS
) {
}
public function verify(string $payload, string $signatureHeader, string $secret): bool
{
$parts = $this->parseStripeHeader($signatureHeader);
$timestamp = $parts['t'] ?? 0;
$signature = $parts['v1'] ?? '';
// Check timestamp tolerance
if (abs(time() - $timestamp) > $this->tolerance) {
return false;
}
// Verify signature
$signedPayload = $timestamp . '.' . $payload;
return $this->hmacService->verifyHmacString(
$signedPayload,
$signature,
$secret,
HashAlgorithm::SHA256
);
}
public function parseSignature(string $headerValue): WebhookSignature
{
return WebhookSignature::fromStripeHeader($headerValue);
}
public function generateSignature(string $payload, string $secret): string
{
$timestamp = time();
$signedPayload = $timestamp . '.' . $payload;
$hash = $this->hmacService->generateHmac($signedPayload, $secret, HashAlgorithm::SHA256);
return "t={$timestamp},v1=" . $hash->toString();
}
public function getSignatureHeader(): string
{
return 'Stripe-Signature';
}
public function getProviderName(): string
{
return 'stripe';
}
private function parseStripeHeader(string $signatureHeader): array
{
$parts = [];
foreach (explode(',', $signatureHeader) as $part) {
if (str_contains($part, '=')) {
[$key, $value] = explode('=', $part, 2);
$parts[$key] = $value;
}
}
if (empty($parts['v1'])) {
throw new InvalidArgumentException('Invalid Stripe signature format: missing v1 signature');
}
return $parts;
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Security;
use App\Framework\Encryption\HmacService;
use App\Framework\Webhook\Security\Providers\GenericHmacProvider;
use App\Framework\Webhook\Security\Providers\GitHubSignatureProvider;
use App\Framework\Webhook\Security\Providers\LegalServiceProvider;
use App\Framework\Webhook\Security\Providers\SignatureProvider;
use App\Framework\Webhook\Security\Providers\StripeSignatureProvider;
use App\Framework\Webhook\ValueObjects\WebhookProvider;
use InvalidArgumentException;
/**
* Multi-provider webhook signature verification service
* Uses framework's Encryption module and Hash system for secure verification
*/
final readonly class SignatureVerifier
{
/** @var array<string, SignatureProvider> */
private array $providers;
public function __construct(HmacService $hmacService)
{
$this->providers = [
'stripe' => new StripeSignatureProvider($hmacService),
'github' => new GitHubSignatureProvider($hmacService),
'legal-service' => new LegalServiceProvider($hmacService),
'generic' => new GenericHmacProvider($hmacService),
];
}
/**
* Verify webhook signature using appropriate provider
*/
public function verify(
string $payload,
string $signatureHeader,
string $secret,
WebhookProvider $provider
): bool {
$signatureProvider = $this->getProvider($provider);
return $signatureProvider->verify($payload, $signatureHeader, $secret);
}
/**
* Generate signature for outgoing webhooks
*/
public function generateSignature(
string $payload,
string $secret,
WebhookProvider $provider
): string {
$signatureProvider = $this->getProvider($provider);
return $signatureProvider->generateSignature($payload, $secret);
}
/**
* Get signature header name for provider
*/
public function getSignatureHeader(WebhookProvider $provider): string
{
$signatureProvider = $this->getProvider($provider);
return $signatureProvider->getSignatureHeader();
}
/**
* Add custom signature provider
*/
public function addProvider(string $name, SignatureProvider $provider): void
{
$this->providers[$name] = $provider;
}
/**
* Check if provider is supported
*/
public function hasProvider(WebhookProvider $provider): bool
{
return isset($this->providers[$provider->toString()]);
}
/**
* Get all registered provider names
*/
public function getProviderNames(): array
{
return array_keys($this->providers);
}
private function getProvider(WebhookProvider $provider): SignatureProvider
{
$providerName = $provider->toString();
if (! isset($this->providers[$providerName])) {
throw new InvalidArgumentException("Unsupported webhook provider: {$providerName}");
}
return $this->providers[$providerName];
}
}

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Sending;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Queue;
use App\Framework\Webhook\Jobs\WebhookJob;
use App\Framework\Webhook\ValueObjects\WebhookPayload;
use App\Framework\Webhook\ValueObjects\WebhookProvider;
use DateTimeImmutable;
/**
* Webhook scheduler using framework's Queue system
* Manages async webhook delivery with priority and scheduling
*/
final readonly class WebhookScheduler
{
public function __construct(
private Queue $queue,
private Logger $logger
) {
}
/**
* Schedule webhook for immediate delivery
*/
public function send(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
array $options = []
): string {
$job = WebhookJob::immediate($url, $payload, $provider, $secret, $options);
$this->queue->push($job);
$jobId = $job->getJobId();
$this->logger->info('Webhook scheduled for immediate delivery', [
'job_id' => $jobId,
'url' => $url,
'provider' => $provider->toString(),
'priority' => $job->getPriority(),
]);
return $jobId;
}
/**
* Schedule webhook for future delivery
*/
public function sendAt(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
DateTimeImmutable $scheduledAt,
array $options = []
): string {
$job = WebhookJob::delayed($url, $payload, $provider, $secret, $scheduledAt, $options);
$this->queue->push($job);
$jobId = $job->getJobId();
$this->logger->info('Webhook scheduled for future delivery', [
'job_id' => $jobId,
'url' => $url,
'provider' => $provider->toString(),
'scheduled_at' => $scheduledAt->format('c'),
'delay_seconds' => $scheduledAt->getTimestamp() - time(),
]);
return $jobId;
}
/**
* Schedule webhook with delay in seconds
*/
public function sendIn(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
int $delaySeconds,
array $options = []
): string {
$scheduledAt = (new DateTimeImmutable())->modify("+{$delaySeconds} seconds");
return $this->sendAt($url, $payload, $provider, $secret, $scheduledAt, $options);
}
/**
* Schedule high priority webhook
*/
public function sendUrgent(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
array $options = []
): string {
$options['priority'] = 10; // High priority
return $this->send($url, $payload, $provider, $secret, $options);
}
/**
* Schedule webhook with custom retry configuration
*/
public function sendWithRetries(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
int $maxRetries,
array $options = []
): string {
$options['max_retries'] = $maxRetries;
return $this->send($url, $payload, $provider, $secret, $options);
}
/**
* Bulk schedule multiple webhooks
*/
public function sendBulk(array $webhooks): array
{
$jobIds = [];
foreach ($webhooks as $webhook) {
$jobId = $this->send(
url: $webhook['url'],
payload: $webhook['payload'],
provider: $webhook['provider'],
secret: $webhook['secret'],
options: $webhook['options'] ?? []
);
$jobIds[] = $jobId;
}
$this->logger->info('Bulk webhooks scheduled', [
'count' => count($webhooks),
'job_ids' => $jobIds,
]);
return $jobIds;
}
/**
* Schedule webhook with specific event data
*/
public function sendEvent(
string $url,
string $eventType,
array $eventData,
WebhookProvider $provider,
string $secret,
array $options = []
): string {
$payload = WebhookPayload::create($eventData, $eventType);
return $this->send($url, $payload, $provider, $secret, $options);
}
/**
* Get queue statistics for monitoring
*/
public function getQueueStats(): array
{
// This would depend on the queue implementation
// For now, return basic info
return [
'queue_type' => get_class($this->queue),
'timestamp' => (new DateTimeImmutable())->format('c'),
];
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Sending;
/**
* Result of webhook sending operation
* Contains all information about the webhook request and response
*/
final readonly class WebhookSendResult
{
public function __construct(
public string $webhookId,
public bool $success,
public int $statusCode,
public string $responseBody,
public array $responseHeaders,
public float $requestTime,
public int $attempt = 1,
public ?string $error = null
) {
}
/**
* Check if webhook was sent successfully
*/
public function isSuccessful(): bool
{
return $this->success;
}
/**
* Check if webhook failed
*/
public function isFailed(): bool
{
return ! $this->success;
}
/**
* Check if failure was due to client error (4xx)
*/
public function isClientError(): bool
{
return $this->statusCode >= 400 && $this->statusCode < 500;
}
/**
* Check if failure was due to server error (5xx)
*/
public function isServerError(): bool
{
return $this->statusCode >= 500;
}
/**
* Check if request was rate limited
*/
public function isRateLimited(): bool
{
return $this->statusCode === 429;
}
/**
* Check if failure should be retried
*/
public function shouldRetry(): bool
{
// Retry on server errors, rate limiting, or connection errors
return $this->isServerError() || $this->isRateLimited() || $this->statusCode === 0;
}
/**
* Get response as JSON array if possible
*/
public function getJsonResponse(): ?array
{
if (empty($this->responseBody)) {
return null;
}
try {
return json_decode($this->responseBody, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
}
/**
* Get specific response header
*/
public function getResponseHeader(string $header): ?string
{
$header = strtolower($header);
foreach ($this->responseHeaders as $key => $value) {
if (strtolower($key) === $header) {
return is_array($value) ? $value[0] : $value;
}
}
return null;
}
/**
* Convert result to array for logging/serialization
*/
public function toArray(): array
{
return [
'webhook_id' => $this->webhookId,
'success' => $this->success,
'status_code' => $this->statusCode,
'request_time' => $this->requestTime,
'attempt' => $this->attempt,
'error' => $this->error,
'response_size' => strlen($this->responseBody),
'has_json_response' => $this->getJsonResponse() !== null,
];
}
}

View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\Sending;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Http\Headers;
use App\Framework\Http\Method;
use App\Framework\HttpClient\ClientOptions;
use App\Framework\HttpClient\ClientRequest;
use App\Framework\HttpClient\HttpClient;
use App\Framework\Logging\Logger;
use App\Framework\Webhook\Events\WebhookFailed;
use App\Framework\Webhook\Events\WebhookSent;
use App\Framework\Webhook\Security\SignatureVerifier;
use App\Framework\Webhook\ValueObjects\WebhookPayload;
use App\Framework\Webhook\ValueObjects\WebhookProvider;
/**
* Webhook sender service using framework's HttpClient
* Handles outgoing webhook requests with signature generation and retry logic
*/
final readonly class WebhookSender
{
public function __construct(
private HttpClient $httpClient,
private SignatureVerifier $signatureVerifier,
private EventDispatcher $eventDispatcher,
private Logger $logger
) {
}
/**
* Send webhook to endpoint with signature
*/
public function send(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
array $options = []
): WebhookSendResult {
$webhookId = $this->generateWebhookId();
$startTime = microtime(true);
try {
// Generate signature
$signature = $this->signatureVerifier->generateSignature(
payload: $payload->rawBody,
secret: $secret,
provider: $provider
);
// Prepare headers
$headers = new Headers();
$headers = $headers->with('Content-Type', 'application/json');
$headers = $headers->with('User-Agent', 'Framework-Webhook-Sender/1.0');
// Add provider-specific signature header
$signatureHeader = $this->signatureVerifier->getSignatureHeader($provider);
$headers = $headers->with($signatureHeader, $signature);
// Add custom headers from options
if (isset($options['headers']) && is_array($options['headers'])) {
foreach ($options['headers'] as $key => $value) {
$headers = $headers->with($key, $value);
}
}
// Prepare client options
$clientOptions = new ClientOptions(
timeout: $options['timeout'] ?? 30,
connectTimeout: $options['connect_timeout'] ?? 10,
verifySSL: $options['verify_ssl'] ?? true
);
// Create and send request
$request = new ClientRequest(
method: Method::POST,
url: $url,
headers: $headers,
body: $payload->rawBody,
options: $clientOptions
);
$response = $this->httpClient->send($request);
$endTime = microtime(true);
// Create result
$result = new WebhookSendResult(
webhookId: $webhookId,
success: $response->isSuccessful(),
statusCode: $response->status->value,
responseBody: $response->body,
responseHeaders: $response->headers->toArray(),
requestTime: $endTime - $startTime,
attempt: $options['attempt'] ?? 1
);
// Log and dispatch events
if ($result->success) {
$this->logger->info('Webhook sent successfully', [
'webhook_id' => $webhookId,
'url' => $url,
'provider' => $provider->toString(),
'status_code' => $result->statusCode,
'request_time' => $result->requestTime,
]);
$this->eventDispatcher->dispatch(
WebhookSent::create($provider, $payload, $url, $result)
);
} else {
$this->logger->warning('Webhook failed with error response', [
'webhook_id' => $webhookId,
'url' => $url,
'provider' => $provider->toString(),
'status_code' => $result->statusCode,
'response_body' => $result->responseBody,
]);
$this->eventDispatcher->dispatch(
WebhookFailed::create($provider, $url, "HTTP {$result->statusCode}", $payload)
);
}
return $result;
} catch (\Exception $e) {
$endTime = microtime(true);
$result = new WebhookSendResult(
webhookId: $webhookId,
success: false,
statusCode: 0,
responseBody: '',
responseHeaders: [],
requestTime: $endTime - $startTime,
attempt: $options['attempt'] ?? 1,
error: $e->getMessage()
);
$this->logger->error('Webhook sending failed with exception', [
'webhook_id' => $webhookId,
'url' => $url,
'provider' => $provider->toString(),
'error' => $e->getMessage(),
'attempt' => $options['attempt'] ?? 1,
]);
$this->eventDispatcher->dispatch(
WebhookFailed::create($provider, $url, $e->getMessage(), $payload)
);
return $result;
}
}
/**
* Send webhook with automatic retry logic
*/
public function sendWithRetry(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
int $maxRetries = 3,
array $options = []
): WebhookSendResult {
$lastResult = null;
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
$attemptOptions = [...$options, 'attempt' => $attempt];
$result = $this->send($url, $payload, $provider, $secret, $attemptOptions);
if ($result->success) {
return $result;
}
$lastResult = $result;
// Don't retry on certain HTTP status codes
if ($this->shouldNotRetry($result->statusCode)) {
$this->logger->info('Not retrying webhook due to status code', [
'webhook_id' => $result->webhookId,
'status_code' => $result->statusCode,
'url' => $url,
]);
break;
}
// Wait before retry (exponential backoff)
if ($attempt < $maxRetries) {
$waitTime = $this->calculateBackoffDelay($attempt);
$this->logger->debug('Waiting before webhook retry', [
'webhook_id' => $result->webhookId,
'attempt' => $attempt,
'wait_seconds' => $waitTime,
]);
sleep($waitTime);
}
}
return $lastResult;
}
/**
* Check if HTTP status code should not be retried
*/
private function shouldNotRetry(int $statusCode): bool
{
// Don't retry client errors (4xx) except rate limiting
return $statusCode >= 400 && $statusCode < 500 && $statusCode !== 429;
}
/**
* Calculate exponential backoff delay
*/
private function calculateBackoffDelay(int $attempt): int
{
return min(60, pow(2, $attempt - 1)); // Max 60 seconds
}
/**
* Generate unique webhook ID for tracking
*/
private function generateWebhookId(): string
{
return 'whout_' . bin2hex(random_bytes(8)) . '_' . time();
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\ValueObjects;
use App\Framework\Core\ValueObjects\Hash;
use JsonSerializable;
/**
* Immutable value object representing a webhook payload
* Provides typed access to webhook data with validation
*/
final readonly class WebhookPayload implements JsonSerializable
{
private function __construct(
public array $data,
public string $rawBody,
public array $headers,
public ?string $eventType = null,
public ?string $webhookId = null
) {
}
public static function fromRequest(array $data, string $rawBody, array $headers): self
{
return new self(
data: $data,
rawBody: $rawBody,
headers: $headers,
eventType: $headers['X-Event-Type'] ?? $headers['x-event-type'] ?? null,
webhookId: $headers['X-Webhook-ID'] ?? $headers['x-webhook-id'] ?? null
);
}
public static function create(array $data, ?string $eventType = null): self
{
$rawBody = json_encode($data, JSON_THROW_ON_ERROR);
return new self(
data: $data,
rawBody: $rawBody,
headers: [],
eventType: $eventType,
webhookId: null
);
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
public function has(string $key): bool
{
return array_key_exists($key, $this->data);
}
public function getEventType(): ?string
{
return $this->eventType ?? $this->get('type') ?? $this->get('event');
}
public function getWebhookId(): string
{
return $this->webhookId ?? $this->get('id') ?? Hash::random()->toString();
}
public function jsonSerialize(): array
{
return $this->data;
}
public function toArray(): array
{
return $this->data;
}
public function isEmpty(): bool
{
return empty($this->data);
}
public function getHeader(string $name): ?string
{
return $this->headers[$name] ?? $this->headers[strtolower($name)] ?? null;
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\ValueObjects;
use InvalidArgumentException;
/**
* Value object for webhook provider identification
* Provides standardized provider handling with known configurations
*/
final readonly class WebhookProvider
{
private function __construct(
public string $name,
public string $signatureAlgorithm = 'sha256',
public string $signatureHeader = 'X-Signature',
public string $eventTypeHeader = 'X-Event-Type'
) {
if (empty(trim($name))) {
throw new InvalidArgumentException('Provider name cannot be empty');
}
}
public static function create(string $name): self
{
return new self(strtolower(trim($name)));
}
public static function stripe(): self
{
return new self(
name: 'stripe',
signatureAlgorithm: 'sha256',
signatureHeader: 'Stripe-Signature',
eventTypeHeader: 'Stripe-Event'
);
}
public static function github(): self
{
return new self(
name: 'github',
signatureAlgorithm: 'sha256',
signatureHeader: 'X-Hub-Signature-256',
eventTypeHeader: 'X-GitHub-Event'
);
}
public static function generic(string $name): self
{
return new self(
name: strtolower(trim($name)),
signatureAlgorithm: 'sha256',
signatureHeader: 'X-Signature',
eventTypeHeader: 'X-Event-Type'
);
}
public static function legalService(): self
{
return new self(
name: 'legal-service',
signatureAlgorithm: 'sha256',
signatureHeader: 'X-Legal-Signature',
eventTypeHeader: 'X-Legal-Event-Type'
);
}
public function toString(): string
{
return $this->name;
}
public function equals(self $other): bool
{
return $this->name === $other->name;
}
public function isKnownProvider(): bool
{
return in_array($this->name, ['stripe', 'github', 'paypal', 'legal-service'], true);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\ValueObjects;
use InvalidArgumentException;
/**
* Immutable value object representing a webhook signature
* Handles various signature formats and encoding schemes
*/
final readonly class WebhookSignature
{
private function __construct(
public string $value,
public string $algorithm,
public string $encoding = 'hex'
) {
if (empty(trim($value))) {
throw new InvalidArgumentException('Webhook signature cannot be empty');
}
}
public static function create(string $value, string $algorithm = 'sha256', string $encoding = 'hex'): self
{
return new self($value, $algorithm, $encoding);
}
public static function fromHeader(string $headerValue): self
{
// Handle various header formats:
// - "sha256=abc123def456"
// - "v1=abc123def456"
// - "abc123def456"
if (str_contains($headerValue, '=')) {
[$algorithm, $signature] = explode('=', $headerValue, 2);
return new self($signature, $algorithm);
}
return new self($headerValue, 'sha256');
}
public static function fromStripeHeader(string $headerValue): self
{
// Stripe format: "t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
$parts = [];
foreach (explode(',', $headerValue) as $part) {
[$key, $value] = explode('=', $part, 2);
$parts[$key] = $value;
}
$signature = $parts['v1'] ?? '';
if (empty($signature)) {
throw new InvalidArgumentException('Invalid Stripe signature format');
}
return new self($signature, 'sha256');
}
public function toString(): string
{
return $this->value;
}
public function toHeaderFormat(?string $prefix = null): string
{
$prefix = $prefix ?? $this->algorithm;
return $prefix . '=' . $this->value;
}
public function equals(self $other): bool
{
return hash_equals($this->value, $other->value) &&
$this->algorithm === $other->algorithm;
}
public function verify(string $payload, string $secret): bool
{
$expectedSignature = match ($this->algorithm) {
'sha1' => hash_hmac('sha1', $payload, $secret),
'sha256' => hash_hmac('sha256', $payload, $secret),
'sha512' => hash_hmac('sha512', $payload, $secret),
default => throw new InvalidArgumentException("Unsupported algorithm: {$this->algorithm}")
};
return hash_equals($this->value, $expectedSignature);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook\ValueObjects;
/**
* Enum for webhook direction types
* Distinguishes between incoming and outgoing webhook processing
*/
enum WebhookType: string
{
case INCOMING = 'incoming';
case OUTGOING = 'outgoing';
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Framework\Webhook;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\HttpResponse;
use App\Framework\Webhook\Processing\WebhookRequestHandler;
use App\Framework\Webhook\Sending\WebhookScheduler;
use App\Framework\Webhook\Sending\WebhookSender;
use App\Framework\Webhook\ValueObjects\WebhookPayload;
use App\Framework\Webhook\ValueObjects\WebhookProvider;
use DateTimeImmutable;
/**
* Unified webhook service facade
* Provides high-level API for both incoming and outgoing webhooks
*/
final readonly class WebhookService
{
public function __construct(
private WebhookRequestHandler $incomingHandler,
private WebhookSender $sender,
private WebhookScheduler $scheduler
) {
}
// === Incoming Webhooks ===
/**
* Process incoming webhook request
*/
public function processIncoming(
HttpRequest $request,
WebhookProvider $provider,
string $secret,
array $allowedEvents = []
): HttpResponse {
return $this->incomingHandler->handle($request, $provider, $secret, $allowedEvents);
}
// === Outgoing Webhooks - Synchronous ===
/**
* Send webhook immediately (synchronous)
*/
public function send(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
array $options = []
) {
return $this->sender->send($url, $payload, $provider, $secret, $options);
}
/**
* Send webhook with automatic retries (synchronous)
*/
public function sendWithRetry(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
int $maxRetries = 3,
array $options = []
) {
return $this->sender->sendWithRetry($url, $payload, $provider, $secret, $maxRetries, $options);
}
// === Outgoing Webhooks - Asynchronous (Queue) ===
/**
* Schedule webhook for asynchronous delivery
*/
public function schedule(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
array $options = []
): string {
return $this->scheduler->send($url, $payload, $provider, $secret, $options);
}
/**
* Schedule webhook for future delivery
*/
public function scheduleAt(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
DateTimeImmutable $scheduledAt,
array $options = []
): string {
return $this->scheduler->sendAt($url, $payload, $provider, $secret, $scheduledAt, $options);
}
/**
* Schedule webhook with delay in seconds
*/
public function scheduleIn(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
int $delaySeconds,
array $options = []
): string {
return $this->scheduler->sendIn($url, $payload, $provider, $secret, $delaySeconds, $options);
}
/**
* Schedule high priority webhook
*/
public function scheduleUrgent(
string $url,
WebhookPayload $payload,
WebhookProvider $provider,
string $secret,
array $options = []
): string {
return $this->scheduler->sendUrgent($url, $payload, $provider, $secret, $options);
}
// === Convenience Methods ===
/**
* Send event webhook (creates payload automatically)
*/
public function sendEvent(
string $url,
string $eventType,
array $eventData,
WebhookProvider $provider,
string $secret,
bool $async = true,
array $options = []
) {
$payload = WebhookPayload::create($eventData, $eventType);
if ($async) {
return $this->schedule($url, $payload, $provider, $secret, $options);
}
return $this->send($url, $payload, $provider, $secret, $options);
}
/**
* Send legal content update webhook (for impressum/privacy updates)
*/
public function sendLegalUpdate(
string $url,
array $legalData,
string $secret,
bool $async = true,
array $options = []
) {
$provider = WebhookProvider::fromString('legal-service');
$payload = WebhookPayload::create($legalData, 'legal.content.updated');
if ($async) {
return $this->schedule($url, $payload, $provider, $secret, $options);
}
return $this->send($url, $payload, $provider, $secret, $options);
}
/**
* Bulk send multiple webhooks
*/
public function sendBulk(array $webhooks, bool $async = true): array
{
if ($async) {
return $this->scheduler->sendBulk($webhooks);
}
$results = [];
foreach ($webhooks as $webhook) {
$results[] = $this->send(
url: $webhook['url'],
payload: $webhook['payload'],
provider: $webhook['provider'],
secret: $webhook['secret'],
options: $webhook['options'] ?? []
);
}
return $results;
}
// === Provider Management ===
/**
* Create webhook payload from data
*/
public function createPayload(array $data, ?string $eventType = null): WebhookPayload
{
return WebhookPayload::create($data, $eventType);
}
/**
* Create webhook provider
*/
public function createProvider(string $provider): WebhookProvider
{
return WebhookProvider::fromString($provider);
}
// === Statistics & Monitoring ===
/**
* Get queue statistics
*/
public function getQueueStats(): array
{
return $this->scheduler->getQueueStats();
}
}