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:
25
src/Framework/Webhook/Attributes/WebhookAuth.php
Normal file
25
src/Framework/Webhook/Attributes/WebhookAuth.php
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
25
src/Framework/Webhook/Attributes/WebhookEndpoint.php
Normal file
25
src/Framework/Webhook/Attributes/WebhookEndpoint.php
Normal 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
24
src/Framework/Webhook/Attributes/WebhookSignature.php
Normal file
24
src/Framework/Webhook/Attributes/WebhookSignature.php
Normal 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 = ''
|
||||
) {
|
||||
}
|
||||
}
|
||||
105
src/Framework/Webhook/Events/WebhookFailed.php
Normal file
105
src/Framework/Webhook/Events/WebhookFailed.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
65
src/Framework/Webhook/Events/WebhookReceived.php
Normal file
65
src/Framework/Webhook/Events/WebhookReceived.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
73
src/Framework/Webhook/Events/WebhookSent.php
Normal file
73
src/Framework/Webhook/Events/WebhookSent.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
201
src/Framework/Webhook/Jobs/WebhookJob.php
Normal file
201
src/Framework/Webhook/Jobs/WebhookJob.php
Normal 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'] ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
155
src/Framework/Webhook/Middleware/WebhookMiddleware.php
Normal file
155
src/Framework/Webhook/Middleware/WebhookMiddleware.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
189
src/Framework/Webhook/Processing/IdempotencyService.php
Normal file
189
src/Framework/Webhook/Processing/IdempotencyService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
80
src/Framework/Webhook/Processing/WebhookJobProcessor.php
Normal file
80
src/Framework/Webhook/Processing/WebhookJobProcessor.php
Normal 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
|
||||
}
|
||||
}
|
||||
203
src/Framework/Webhook/Processing/WebhookRequestHandler.php
Normal file
203
src/Framework/Webhook/Processing/WebhookRequestHandler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
106
src/Framework/Webhook/Security/SignatureVerifier.php
Normal file
106
src/Framework/Webhook/Security/SignatureVerifier.php
Normal 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];
|
||||
}
|
||||
}
|
||||
182
src/Framework/Webhook/Sending/WebhookScheduler.php
Normal file
182
src/Framework/Webhook/Sending/WebhookScheduler.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
122
src/Framework/Webhook/Sending/WebhookSendResult.php
Normal file
122
src/Framework/Webhook/Sending/WebhookSendResult.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
234
src/Framework/Webhook/Sending/WebhookSender.php
Normal file
234
src/Framework/Webhook/Sending/WebhookSender.php
Normal 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();
|
||||
}
|
||||
}
|
||||
88
src/Framework/Webhook/ValueObjects/WebhookPayload.php
Normal file
88
src/Framework/Webhook/ValueObjects/WebhookPayload.php
Normal 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;
|
||||
}
|
||||
}
|
||||
85
src/Framework/Webhook/ValueObjects/WebhookProvider.php
Normal file
85
src/Framework/Webhook/ValueObjects/WebhookProvider.php
Normal 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);
|
||||
}
|
||||
}
|
||||
92
src/Framework/Webhook/ValueObjects/WebhookSignature.php
Normal file
92
src/Framework/Webhook/ValueObjects/WebhookSignature.php
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/Framework/Webhook/ValueObjects/WebhookType.php
Normal file
15
src/Framework/Webhook/ValueObjects/WebhookType.php
Normal 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';
|
||||
}
|
||||
221
src/Framework/Webhook/WebhookService.php
Normal file
221
src/Framework/Webhook/WebhookService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user