prepare webhook support

This commit is contained in:
Kevin Papst
2025-12-25 09:51:36 +01:00
parent 806bb97e60
commit ef170a8ca7
14 changed files with 638 additions and 1 deletions

View File

@@ -21,3 +21,7 @@ indent_size = 4
[*.yml]
indent_style = space
indent_size = 4
[*.xml]
indent_style = space
indent_size = 4

View File

@@ -45,3 +45,7 @@ APP_SECRET=change_this_to_something_unique
#================================================================================
# unlikely, that you need to change this one
CORS_ALLOW_ORIGIN=^https?://localhost(:[0-9]+)?$
###> symfony/messenger ###
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###

View File

@@ -74,6 +74,7 @@
"symfony/translation": "^6.0",
"symfony/twig-bundle": "^6.0",
"symfony/validator": "^6.0",
"symfony/webhook": "^6.0",
"symfony/webpack-encore-bundle": "^2.0",
"symfony/yaml": "^6.0",
"twig/cssinliner-extra": "^3.0",

234
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "9f49e3cfd10e3bdd29e6bd67968ec19b",
"content-hash": "9d1ceee2eb5d9ad5d14add5cd65f49d2",
"packages": [
{
"name": "azuyalabs/yasumi",
@@ -7725,6 +7725,97 @@
],
"time": "2025-10-24T13:29:09+00:00"
},
{
"name": "symfony/messenger",
"version": "v6.4.28",
"source": {
"type": "git",
"url": "https://github.com/symfony/messenger.git",
"reference": "6a758210a2392c4a326de84a6c8c70e7e18296f9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/messenger/zipball/6a758210a2392c4a326de84a6c8c70e7e18296f9",
"reference": "6a758210a2392c4a326de84a6c8c70e7e18296f9",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^1|^2|^3",
"symfony/clock": "^6.3|^7.0",
"symfony/deprecation-contracts": "^2.5|^3"
},
"conflict": {
"symfony/console": "<6.3",
"symfony/event-dispatcher": "<5.4",
"symfony/event-dispatcher-contracts": "<2.5",
"symfony/framework-bundle": "<5.4",
"symfony/http-kernel": "<5.4",
"symfony/serializer": "<5.4"
},
"require-dev": {
"psr/cache": "^1.0|^2.0|^3.0",
"symfony/console": "^6.3|^7.0",
"symfony/dependency-injection": "^5.4|^6.0|^7.0",
"symfony/event-dispatcher": "^5.4|^6.0|^7.0",
"symfony/http-kernel": "^5.4|^6.0|^7.0",
"symfony/process": "^5.4|^6.0|^7.0",
"symfony/property-access": "^5.4|^6.0|^7.0",
"symfony/rate-limiter": "^5.4|^6.0|^7.0",
"symfony/routing": "^5.4|^6.0|^7.0",
"symfony/serializer": "^5.4|^6.0|^7.0",
"symfony/service-contracts": "^2.5|^3",
"symfony/stopwatch": "^5.4|^6.0|^7.0",
"symfony/validator": "^5.4|^6.0|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Messenger\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Samuel Roze",
"email": "samuel.roze@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Helps applications send and receive messages to/from other applications or via message queues",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/messenger/tree/v6.4.28"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-11-06T11:12:43+00:00"
},
{
"name": "symfony/mime",
"version": "v6.4.30",
@@ -8595,6 +8686,75 @@
],
"time": "2025-07-10T08:14:14+00:00"
},
{
"name": "symfony/remote-event",
"version": "v6.4.24",
"source": {
"type": "git",
"url": "https://github.com/symfony/remote-event.git",
"reference": "920e68c4f09f460609cf39a3540f1aa19da72c21"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/remote-event/zipball/920e68c4f09f460609cf39a3540f1aa19da72c21",
"reference": "920e68c4f09f460609cf39a3540f1aa19da72c21",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/messenger": "^5.4|^6.1|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\RemoteEvent\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Eases handling remote events",
"homepage": "https://symfony.com",
"keywords": [
"event"
],
"support": {
"source": "https://github.com/symfony/remote-event/tree/v6.4.24"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-07-10T08:14:14+00:00"
},
{
"name": "symfony/routing",
"version": "v6.4.30",
@@ -10131,6 +10291,78 @@
],
"time": "2025-09-11T09:57:09+00:00"
},
{
"name": "symfony/webhook",
"version": "v6.4.24",
"source": {
"type": "git",
"url": "https://github.com/symfony/webhook.git",
"reference": "95d6f437a17224029983f2bed3cfaca09e530a17"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/webhook/zipball/95d6f437a17224029983f2bed3cfaca09e530a17",
"reference": "95d6f437a17224029983f2bed3cfaca09e530a17",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/http-foundation": "^6.3|^7.0",
"symfony/http-kernel": "^6.3|^7.0",
"symfony/messenger": "^5.4|^6.1|^7.0",
"symfony/remote-event": "^6.3|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Webhook\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Eases sending and consuming webhooks",
"homepage": "https://symfony.com",
"keywords": [
"webhook"
],
"support": {
"source": "https://github.com/symfony/webhook/tree/v6.4.24"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-07-10T08:14:14+00:00"
},
{
"name": "symfony/webpack-encore-bundle",
"version": "v2.4.0",

View File

@@ -0,0 +1,22 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'

View File

@@ -0,0 +1,3 @@
#webhook:
# resource: '@FrameworkBundle/Resources/config/routing/webhook.xml'
# prefix: /webhook

View File

@@ -38,6 +38,13 @@
GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1;
-->
<env name="BOOTSTRAP_RESET_DATABASE" value="true"/>
<!-- ###+ symfony/messenger ### -->
<!-- Choose one of the transports below -->
<!-- MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages -->
<!-- MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages -->
<env name="MESSENGER_TRANSPORT_DSN" value="doctrine://default?auto_setup=0"/>
<!-- ###- symfony/messenger ### -->
</php>
<testsuites>
<testsuite name="Kimai">

View File

@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
class WebhookConfiguration
{
private string $name;
private string $url;
private string $transport;
private string $secret;
private string $authentication;
public function __construct(
string $name,
string $url,
string $transport,
#[\SensitiveParameter]
string $secret,
string $authentication
)
{
$this->name = $name;
$this->url = $url;
$this->transport = $transport;
$this->secret = $secret;
$this->authentication = $authentication;
}
public function getName(): string
{
return $this->name;
}
public function getUrl(): string
{
return $this->url;
}
public function getTransport(): string
{
return $this->transport;
}
public function getSecret(): string
{
return $this->secret;
}
public function getAuthentication(): string
{
return $this->authentication;
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
class WebhookEvent
{
private string $name;
private WebhookConfiguration $configuration;
public function __construct(string $name, WebhookConfiguration $configuration)
{
$this->name = $name;
$this->configuration = $configuration;
}
public function getName(): string
{
return $this->name;
}
public function getConfiguration(): WebhookConfiguration
{
return $this->configuration;
}
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Serializer;
use JMS\Serializer\Context;
use JMS\Serializer\DeserializationContext;
use JMS\Serializer\SerializationContext;
use JMS\Serializer\Serializer as CoreSerializer;
use JMS\Serializer\SerializerInterface as CoreSerializerInterface;
class Serializer implements SerializerInterface
{
public function __construct(private readonly CoreSerializerInterface $serializer)
{
}
private function prepareContext(Context $jmsContext, array $context = []): void
{
$groups = ['Default'];
if (\array_key_exists('groups', $context)) {
$groups = $context['groups'];
}
$jmsContext->setGroups($groups);
$jmsContext->enableMaxDepthChecks();
}
public function toArray(mixed $data, array $context = []): array
{
$jmsContext = SerializationContext::create();
$this->prepareContext($jmsContext, $context);
if ($this->serializer instanceof CoreSerializer) {
return $this->serializer->toArray($data, $jmsContext);
}
$json = $this->serializer->serialize($data, 'json', $jmsContext);
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
}
public function serialize(mixed $data, string $format, array $context = []): string
{
$jmsContext = SerializationContext::create();
$this->prepareContext($jmsContext, $context);
return $this->serializer->serialize($data, $format, $jmsContext);
}
public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed
{
$jmsContext = DeserializationContext::create();
$this->prepareContext($jmsContext, $context);
return $this->serializer->deserialize($data, $type, $format, $jmsContext);
}
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Serializer;
use Symfony\Component\Serializer\SerializerInterface as BaseSerializerInterface;
interface SerializerInterface extends BaseSerializerInterface
{
public function toArray(mixed $data, array $context = []): array;
}

View File

@@ -0,0 +1,109 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Webhook;
use App\Event\ActivityCreatePostEvent;
use App\Event\ActivityDeleteEvent;
use App\Event\ActivityUpdatePostEvent;
use App\Event\CustomerCreatePostEvent;
use App\Event\CustomerDeleteEvent;
use App\Event\CustomerUpdatePostEvent;
use App\Event\InvoiceCreatedEvent;
use App\Event\InvoiceDeleteEvent;
use App\Event\ProjectCreatePostEvent;
use App\Event\ProjectDeleteEvent;
use App\Event\ProjectUpdatePostEvent;
use App\Event\TeamCreatePostEvent;
use App\Event\TeamDeleteEvent;
use App\Event\TeamUpdatePostEvent;
use App\Event\TimesheetCreatePostEvent;
use App\Event\TimesheetStopPostEvent;
use App\Event\TimesheetUpdatePostEvent;
use App\Event\UserCreatePostEvent;
use App\Event\UserDeletePostEvent;
use App\Event\UserUpdatePostEvent;
use App\Webhook\Attribute\AsWebhook;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Contracts\EventDispatcher\Event;
final class WebhookListener implements EventSubscriberInterface
{
public function __construct(private readonly WebhookService $webhookService)
{
}
public static function getSubscribedEvents(): array
{
return [
ActivityCreatePostEvent::class => ['triggerWebhook', 1000],
ActivityDeleteEvent::class => ['triggerWebhook', 1000],
ActivityUpdatePostEvent::class => ['triggerWebhook', 1000],
CustomerCreatePostEvent::class => ['triggerWebhook', 1000],
CustomerDeleteEvent::class => ['triggerWebhook', 1000],
CustomerUpdatePostEvent::class => ['triggerWebhook', 1000],
InvoiceCreatedEvent::class => ['triggerWebhook', 1000],
InvoiceDeleteEvent::class => ['triggerWebhook', 1000],
ProjectCreatePostEvent::class => ['triggerWebhook', 1000],
ProjectDeleteEvent::class => ['triggerWebhook', 1000],
ProjectUpdatePostEvent::class => ['triggerWebhook', 1000],
TimesheetCreatePostEvent::class => ['triggerWebhook', 1000],
TimesheetStopPostEvent::class => ['triggerWebhook', 1000],
TimesheetUpdatePostEvent::class => ['triggerWebhook', 1000],
UserCreatePostEvent::class => ['triggerWebhook', 1000],
UserDeletePostEvent::class => ['triggerWebhook', 1000],
UserUpdatePostEvent::class => ['triggerWebhook', 1000],
TeamCreatePostEvent::class => ['triggerWebhook', 1000],
TeamDeleteEvent::class => ['triggerWebhook', 1000],
TeamUpdatePostEvent::class => ['triggerWebhook', 1000],
];
}
public function triggerWebhook(Event $event): void
{
$attribute = $this->findAttribute($event);
if ($attribute === null) {
return;
}
if (!$this->webhookService->hasWebhook($attribute->name)) {
return;
}
$expressionLanguage = new ExpressionLanguage();
$parsed = $expressionLanguage->parse($attribute->payload, ['object']);
$payload = $parsed->getNodes()->evaluate([], ['object' => $event]);
$this->webhookService->trigger($attribute->name, $payload);
}
private function findAttribute(Event $event): ?AsWebhook
{
try {
$reflectionClass = new \ReflectionClass($event);
$attributes = $reflectionClass->getAttributes(AsWebhook::class);
if (\count($attributes) === 1) {
$attribute = $attributes[0];
$args = $attribute->getArguments();
return new AsWebhook($args['name'], $args['description'], $args['payload']);
}
} catch (\Exception $ex) {
}
return null;
}
}

View File

@@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Webhook;
use App\Entity\WebhookConfiguration;
use App\Entity\WebhookEvent;
use App\Serializer\SerializerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Messenger\SendWebhookMessage;
use Symfony\Component\Webhook\Subscriber;
final class WebhookService
{
public function __construct(
private readonly SerializerInterface $serializer,
private readonly MessageBusInterface $bus
)
{
}
public function hasWebhook(string $name): bool
{
return $name === 'user.created';
}
public function trigger(string $name, mixed $payload): void
{
$content = $this->serializer->toArray($payload, ['groups' => ['Default', 'Entity', 'Expanded']]);
foreach ($this->findEventsByName($name) as $event) {
$configuration = $event->getConfiguration();
$subscriber = new Subscriber($configuration->getUrl(), $configuration->getSecret());
$remoteEvent = new RemoteEvent($name, $name . '_' . uniqid(microtime()), $content);
$message = new SendWebhookMessage($subscriber, $remoteEvent);
$this->bus->dispatch($message);
}
}
public function findEventsByName(string $name): array
{
$url = 'TODO';
$secret = 'TODO';
$configuration = new WebhookConfiguration('n8n', $url, 'json', $secret, 'bearer');
return [
new WebhookEvent($name, $configuration),
];
}
}

View File

@@ -467,6 +467,18 @@
"config/packages/mailer.yaml"
]
},
"symfony/messenger": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/mime": {
"version": "v4.3.3"
},
@@ -598,6 +610,18 @@
"ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6"
}
},
"symfony/webhook": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "0bc853a74c98e448116ba53e118a7b6b3cdf9a03"
},
"files": [
"config/routes/webhook.yaml"
]
},
"symfony/webpack-encore-bundle": {
"version": "1.0",
"recipe": {