From 3ce43c425ecdca357110e52edee8e812f414aea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Tue, 13 Aug 2024 10:16:01 +0200 Subject: [PATCH] Log incoming webhooks --- ...llerAction.php => HandleWebhookAction.php} | 26 ++++- src/DependencyInjection/Configuration.php | 15 +++ src/Factory/WebhookFactory.php | 37 ++++++ src/Factory/WebhookFactoryInterface.php | 19 +++ src/Logger/WebhookLogger.php | 20 ++++ src/Model/Webhook.php | 109 ++++++++++++++++++ src/Model/WebhookInterface.php | 46 ++++++++ src/Registrar/WebhookRegistrar.php | 1 + .../config/doctrine/model/Webhook.orm.xml | 21 ++++ src/Resources/config/routes/global.yaml | 2 +- src/Resources/config/services/controller.xml | 4 +- src/Resources/config/services/factory.xml | 5 + .../CompositeWebhookHandler.php | 25 +++- .../OrderPackedWebhookHandler.php | 29 ++++- .../StockAdjustmentWebhookHandler.php | 15 ++- ...onTest.php => HandleWebhookActionTest.php} | 2 +- 16 files changed, 369 insertions(+), 7 deletions(-) rename src/Controller/{HandleWebhookControllerAction.php => HandleWebhookAction.php} (55%) create mode 100644 src/Factory/WebhookFactory.php create mode 100644 src/Factory/WebhookFactoryInterface.php create mode 100644 src/Logger/WebhookLogger.php create mode 100644 src/Model/Webhook.php create mode 100644 src/Model/WebhookInterface.php create mode 100644 src/Resources/config/doctrine/model/Webhook.orm.xml rename tests/Functional/Controller/{HandleWebhookControllerActionTest.php => HandleWebhookActionTest.php} (98%) diff --git a/src/Controller/HandleWebhookControllerAction.php b/src/Controller/HandleWebhookAction.php similarity index 55% rename from src/Controller/HandleWebhookControllerAction.php rename to src/Controller/HandleWebhookAction.php index d31dfce..b5d3ea8 100644 --- a/src/Controller/HandleWebhookControllerAction.php +++ b/src/Controller/HandleWebhookAction.php @@ -4,25 +4,44 @@ namespace Setono\SyliusPeakPlugin\Controller; +use Doctrine\Persistence\ManagerRegistry; +use Psr\Log\LoggerAwareInterface; +use Setono\Doctrine\ORMTrait; use Setono\PeakWMS\Parser\WebhookParser; use Setono\PeakWMS\Parser\WebhookParserInterface; +use Setono\SyliusPeakPlugin\Factory\WebhookFactoryInterface; +use Setono\SyliusPeakPlugin\Logger\WebhookLogger; use Setono\SyliusPeakPlugin\WebhookHandler\WebhookHandlerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -final class HandleWebhookControllerAction +final class HandleWebhookAction { + use ORMTrait; + public function __construct( private readonly WebhookParserInterface $webhookParser, private readonly WebhookHandlerInterface $webhookHandler, + private readonly WebhookFactoryInterface $webhookFactory, + ManagerRegistry $managerRegistry, ) { + $this->managerRegistry = $managerRegistry; } public function __invoke(Request $request): JsonResponse { + $webhook = null; + try { + $webhook = $this->webhookFactory->createFromRequest($request); + $logger = new WebhookLogger($webhook); + + if ($this->webhookHandler instanceof LoggerAwareInterface) { + $this->webhookHandler->setLogger($logger); + } + $dataClass = WebhookParser::convertNameToDataClass($request->query->getInt('name')); $data = $this->webhookParser->parse($request->getContent(), $dataClass); @@ -30,6 +49,11 @@ public function __invoke(Request $request): JsonResponse $this->webhookHandler->handle($data); } catch (\InvalidArgumentException $e) { throw new BadRequestHttpException($e->getMessage()); + } finally { + if (null !== $webhook) { + $this->getManager($webhook)->persist($webhook); + $this->getManager($webhook)->flush(); + } } return new JsonResponse(status: Response::HTTP_NO_CONTENT); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 6dbaf1c..dc5e1ac 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -7,6 +7,7 @@ use Setono\SyliusPeakPlugin\Model\InventoryUpdate; use Setono\SyliusPeakPlugin\Model\UploadOrderRequest; use Setono\SyliusPeakPlugin\Model\UploadProductVariantRequest; +use Setono\SyliusPeakPlugin\Model\Webhook; use Setono\SyliusPeakPlugin\Model\WebhookRegistration; use Sylius\Component\Resource\Factory\Factory; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; @@ -64,6 +65,20 @@ private function addResourcesSection(ArrayNodeDefinition $node): void ->end() ->end() ->end() + ->arrayNode('webhook') + ->addDefaultsIfNotSet() + ->children() + ->variableNode('options')->end() + ->arrayNode('classes') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('model')->defaultValue(Webhook::class)->cannotBeEmpty()->end() + ->scalarNode('repository')->cannotBeEmpty()->end() + ->scalarNode('factory')->defaultValue(Factory::class)->end() + ->end() + ->end() + ->end() + ->end() ->arrayNode('webhook_registration') ->addDefaultsIfNotSet() ->children() diff --git a/src/Factory/WebhookFactory.php b/src/Factory/WebhookFactory.php new file mode 100644 index 0000000..9766978 --- /dev/null +++ b/src/Factory/WebhookFactory.php @@ -0,0 +1,37 @@ +decorated->createNew(); + Assert::isInstanceOf($obj, WebhookInterface::class); + + return $obj; + } + + public function createFromRequest(Request $request): WebhookInterface + { + $obj = $this->createNew(); + $obj->setMethod($request->getMethod()); + $obj->setUrl($request->getUri()); + $obj->setHeaders($request->headers->all()); + $obj->setBody($request->getContent()); + $obj->setRemoteIp($request->getClientIp()); + + return $obj; + } +} diff --git a/src/Factory/WebhookFactoryInterface.php b/src/Factory/WebhookFactoryInterface.php new file mode 100644 index 0000000..d470189 --- /dev/null +++ b/src/Factory/WebhookFactoryInterface.php @@ -0,0 +1,19 @@ + + */ +interface WebhookFactoryInterface extends FactoryInterface +{ + public function createNew(): WebhookInterface; + + public function createFromRequest(Request $request): WebhookInterface; +} diff --git a/src/Logger/WebhookLogger.php b/src/Logger/WebhookLogger.php new file mode 100644 index 0000000..9b63d5c --- /dev/null +++ b/src/Logger/WebhookLogger.php @@ -0,0 +1,20 @@ +webhook->addLog(sprintf('[%s] %s', (new \DateTimeImmutable())->format(\DATE_ATOM), (string) $message)); + } +} diff --git a/src/Model/Webhook.php b/src/Model/Webhook.php new file mode 100644 index 0000000..6eb4ffd --- /dev/null +++ b/src/Model/Webhook.php @@ -0,0 +1,109 @@ +>|null */ + protected ?array $headers = null; + + protected ?string $body = null; + + protected ?string $remoteIp = null; + + protected ?string $log = null; + + protected \DateTimeInterface $createdAt; + + public function __construct() + { + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getMethod(): ?string + { + return $this->method; + } + + public function setMethod(?string $method): void + { + $this->method = $method; + } + + public function getUrl(): ?string + { + return $this->url; + } + + public function setUrl(?string $url): void + { + $this->url = $url; + } + + public function getHeaders(): array + { + return $this->headers ?? []; + } + + public function setHeaders(?array $headers): void + { + $this->headers = $headers; + } + + public function getBody(): ?string + { + return $this->body; + } + + public function setBody(?string $body): void + { + $this->body = $body; + } + + public function getRemoteIp(): ?string + { + return $this->remoteIp; + } + + public function setRemoteIp(?string $remoteIp): void + { + $this->remoteIp = $remoteIp; + } + + public function getLog(): ?string + { + return $this->log; + } + + public function setLog(?string $log): void + { + $this->log = $log; + } + + public function addLog(string $log): void + { + if (null === $this->log) { + $this->log = ''; + } + + $this->log = $log . \PHP_EOL . $this->log; + } + + public function getCreatedAt(): \DateTimeInterface + { + return $this->createdAt; + } +} diff --git a/src/Model/WebhookInterface.php b/src/Model/WebhookInterface.php new file mode 100644 index 0000000..7169894 --- /dev/null +++ b/src/Model/WebhookInterface.php @@ -0,0 +1,46 @@ +> + */ + public function getHeaders(): array; + + /** + * @param array>|null $headers + */ + public function setHeaders(?array $headers): void; + + public function getBody(): ?string; + + public function setBody(?string $body): void; + + public function getRemoteIp(): ?string; + + public function setRemoteIp(?string $remoteIp): void; + + public function getLog(): ?string; + + public function setLog(?string $log): void; + + public function addLog(string $log): void; + + public function getCreatedAt(): \DateTimeInterface; +} diff --git a/src/Registrar/WebhookRegistrar.php b/src/Registrar/WebhookRegistrar.php index 6049691..d6a5a61 100644 --- a/src/Registrar/WebhookRegistrar.php +++ b/src/Registrar/WebhookRegistrar.php @@ -87,6 +87,7 @@ private function getWebhooks(): array foreach ([Name::StockAdjust, Name::PickOrderPacked] as $name) { $webhooks[] = new Webhook( name: $name, + // todo this URL should include some kind of key to make it secure url: $this->urlGenerator->generate( name: 'setono_sylius_peak_global_webhook', parameters: ['name' => $name->value], diff --git a/src/Resources/config/doctrine/model/Webhook.orm.xml b/src/Resources/config/doctrine/model/Webhook.orm.xml new file mode 100644 index 0000000..7292f7e --- /dev/null +++ b/src/Resources/config/doctrine/model/Webhook.orm.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/routes/global.yaml b/src/Resources/config/routes/global.yaml index a9d6693..ca69fd0 100644 --- a/src/Resources/config/routes/global.yaml +++ b/src/Resources/config/routes/global.yaml @@ -2,4 +2,4 @@ setono_sylius_peak_global_webhook: path: /peak/webhook methods: [POST] defaults: - _controller: Setono\SyliusPeakPlugin\Controller\HandleWebhookControllerAction + _controller: Setono\SyliusPeakPlugin\Controller\HandleWebhookAction diff --git a/src/Resources/config/services/controller.xml b/src/Resources/config/services/controller.xml index 8395b24..2c52240 100644 --- a/src/Resources/config/services/controller.xml +++ b/src/Resources/config/services/controller.xml @@ -14,9 +14,11 @@ - + + + diff --git a/src/Resources/config/services/factory.xml b/src/Resources/config/services/factory.xml index 9ba88df..efb8f8c 100644 --- a/src/Resources/config/services/factory.xml +++ b/src/Resources/config/services/factory.xml @@ -4,6 +4,11 @@ + + + + diff --git a/src/WebhookHandler/CompositeWebhookHandler.php b/src/WebhookHandler/CompositeWebhookHandler.php index f098b99..b1fdfc5 100644 --- a/src/WebhookHandler/CompositeWebhookHandler.php +++ b/src/WebhookHandler/CompositeWebhookHandler.php @@ -4,17 +4,33 @@ namespace Setono\SyliusPeakPlugin\WebhookHandler; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Setono\CompositeCompilerPass\CompositeService; use Setono\SyliusPeakPlugin\Exception\UnsupportedWebhookException; /** * @extends CompositeService */ -final class CompositeWebhookHandler extends CompositeService implements WebhookHandlerInterface +final class CompositeWebhookHandler extends CompositeService implements WebhookHandlerInterface, LoggerAwareInterface { + private LoggerInterface $logger; + + public function __construct() + { + $this->logger = new NullLogger(); + } + public function handle(object $data): void { + $this->logger->debug(sprintf('Handling webhook %s', $data::class)); + foreach ($this->services as $service) { + if ($service instanceof LoggerAwareInterface) { + $service->setLogger($this->logger); + } + if ($service->supports($data)) { $service->handle($data); @@ -22,6 +38,8 @@ public function handle(object $data): void } } + $this->logger->critical('The webhook was not supported by any of the webhook handlers'); + throw UnsupportedWebhookException::fromData($data); } @@ -35,4 +53,9 @@ public function supports(object $data): bool return false; } + + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } } diff --git a/src/WebhookHandler/OrderPackedWebhookHandler.php b/src/WebhookHandler/OrderPackedWebhookHandler.php index 068beb7..db69b49 100644 --- a/src/WebhookHandler/OrderPackedWebhookHandler.php +++ b/src/WebhookHandler/OrderPackedWebhookHandler.php @@ -4,6 +4,9 @@ namespace Setono\SyliusPeakPlugin\WebhookHandler; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Setono\PeakWMS\DataTransferObject\Webhook\WebhookDataPickOrderLine; use Setono\PeakWMS\DataTransferObject\Webhook\WebhookDataPickOrderPacked; use Setono\SyliusPeakPlugin\Exception\UnsupportedWebhookException; @@ -15,12 +18,15 @@ use Sylius\Component\Core\Repository\OrderRepositoryInterface; use Webmozart\Assert\Assert; -final class OrderPackedWebhookHandler implements WebhookHandlerInterface +final class OrderPackedWebhookHandler implements WebhookHandlerInterface, LoggerAwareInterface { + private LoggerInterface $logger; + public function __construct( private readonly OrderRepositoryInterface $orderRepository, private readonly FactoryInterface $stateMachineFactory, ) { + $this->logger = new NullLogger(); } /** @@ -37,6 +43,11 @@ public function handle(object $data): void throw new \InvalidArgumentException(sprintf('Order with id "%s" not found', $data->orderId)); } + $this->logger->debug(sprintf('Order state before: %s', $order->getState())); + $this->logger->debug(sprintf('Order checkout state before: %s', (string) $order->getCheckoutState())); + $this->logger->debug(sprintf('Order shipping state state before: %s', (string) $order->getShippingState())); + $this->logger->debug(sprintf('Order payment state before: %s', (string) $order->getPaymentState())); + $syliusOrderLines = array_values(array_map(static fn (OrderItemInterface $orderItem): array => [ 'id' => (string) $orderItem->getId(), 'quantity' => $orderItem->getQuantity(), @@ -56,17 +67,28 @@ public function handle(object $data): void $orderShippingStateMachine = $this->stateMachineFactory->get($order, OrderShippingTransitions::GRAPH); if ($orderShippingStateMachine->can(OrderShippingTransitions::TRANSITION_SHIP)) { + $this->logger->debug(sprintf('Taking the "%s" transition', OrderShippingTransitions::TRANSITION_SHIP)); + $orderShippingStateMachine->apply(OrderShippingTransitions::TRANSITION_SHIP); } if ($data->paymentCaptured) { + $this->logger->debug(sprintf('The payment is captured, so we will check if we can take the "%s" transition', OrderPaymentTransitions::TRANSITION_PAY)); + $orderPaymentStateMachine = $this->stateMachineFactory->get($order, OrderPaymentTransitions::GRAPH); if ($orderPaymentStateMachine->can(OrderPaymentTransitions::TRANSITION_PAY)) { + $this->logger->debug(sprintf('Taking the "%s" transition', OrderPaymentTransitions::TRANSITION_PAY)); + $orderPaymentStateMachine->apply(OrderPaymentTransitions::TRANSITION_PAY); } } + $this->logger->debug(sprintf('Order state after: %s', $order->getState())); + $this->logger->debug(sprintf('Order checkout state after: %s', (string) $order->getCheckoutState())); + $this->logger->debug(sprintf('Order shipping state state after: %s', (string) $order->getShippingState())); + $this->logger->debug(sprintf('Order payment state after: %s', (string) $order->getPaymentState())); + $this->orderRepository->add($order); } @@ -98,4 +120,9 @@ private static function assertSame(array $syliusOrderLines, array $peakOrderLine Assert::count($peakOrderLines, 0); } + + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } } diff --git a/src/WebhookHandler/StockAdjustmentWebhookHandler.php b/src/WebhookHandler/StockAdjustmentWebhookHandler.php index 78ec845..498a885 100644 --- a/src/WebhookHandler/StockAdjustmentWebhookHandler.php +++ b/src/WebhookHandler/StockAdjustmentWebhookHandler.php @@ -4,18 +4,24 @@ namespace Setono\SyliusPeakPlugin\WebhookHandler; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Setono\PeakWMS\DataTransferObject\Webhook\WebhookDataStockAdjust; use Setono\SyliusPeakPlugin\Exception\UnsupportedWebhookException; use Setono\SyliusPeakPlugin\Message\Command\UpdateInventory; use Setono\SyliusPeakPlugin\Provider\ProductVariantProviderInterface; use Symfony\Component\Messenger\MessageBusInterface; -final class StockAdjustmentWebhookHandler implements WebhookHandlerInterface +final class StockAdjustmentWebhookHandler implements WebhookHandlerInterface, LoggerAwareInterface { + private LoggerInterface $logger; + public function __construct( private readonly ProductVariantProviderInterface $productVariantProvider, private readonly MessageBusInterface $commandBus, ) { + $this->logger = new NullLogger(); } public function handle(object $data): void @@ -29,6 +35,8 @@ public function handle(object $data): void throw new \InvalidArgumentException(sprintf('Product variant with id/code "%s" not found', (string) $data->variantId)); } + $this->logger->debug(sprintf('Dispatching a message onto the message to update the inventory for product variant %s', (string) $productVariant->getCode())); + $this->commandBus->dispatch(UpdateInventory::for($productVariant)); } @@ -39,4 +47,9 @@ public function supports(object $data): bool { return $data instanceof WebhookDataStockAdjust; } + + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } } diff --git a/tests/Functional/Controller/HandleWebhookControllerActionTest.php b/tests/Functional/Controller/HandleWebhookActionTest.php similarity index 98% rename from tests/Functional/Controller/HandleWebhookControllerActionTest.php rename to tests/Functional/Controller/HandleWebhookActionTest.php index cec2aaf..c403bf3 100644 --- a/tests/Functional/Controller/HandleWebhookControllerActionTest.php +++ b/tests/Functional/Controller/HandleWebhookActionTest.php @@ -20,7 +20,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Zenstruck\Messenger\Test\InteractsWithMessenger; -final class HandleWebhookControllerActionTest extends WebTestCase +final class HandleWebhookActionTest extends WebTestCase { use InteractsWithMessenger;