Skip to content

Commit 31a06c0

Browse files
Add new make:webhook command
1 parent f6c8719 commit 31a06c0

File tree

9 files changed

+655
-0
lines changed

9 files changed

+655
-0
lines changed

src/Maker/MakeWebhook.php

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony MakerBundle package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\MakerBundle\Maker;
13+
14+
use Symfony\Bundle\MakerBundle\ConsoleStyle;
15+
use Symfony\Bundle\MakerBundle\DependencyBuilder;
16+
use Symfony\Bundle\MakerBundle\FileManager;
17+
use Symfony\Bundle\MakerBundle\Generator;
18+
use Symfony\Bundle\MakerBundle\InputAwareMakerInterface;
19+
use Symfony\Bundle\MakerBundle\InputConfiguration;
20+
use Symfony\Bundle\MakerBundle\Str;
21+
use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
22+
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
23+
use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator;
24+
use Symfony\Bundle\MakerBundle\Validator;
25+
use Symfony\Component\Console\Command\Command;
26+
use Symfony\Component\Console\Input\InputArgument;
27+
use Symfony\Component\Console\Input\InputInterface;
28+
use Symfony\Component\Console\Question\ChoiceQuestion;
29+
use Symfony\Component\Console\Question\Question;
30+
use Symfony\Component\ExpressionLanguage\Expression;
31+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
32+
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
33+
use Symfony\Component\HttpFoundation\Exception\JsonException;
34+
use Symfony\Component\HttpFoundation\Request;
35+
use Symfony\Component\HttpFoundation\RequestMatcher\AttributesRequestMatcher;
36+
use Symfony\Component\HttpFoundation\RequestMatcher\ExpressionRequestMatcher;
37+
use Symfony\Component\HttpFoundation\RequestMatcher\HostRequestMatcher;
38+
use Symfony\Component\HttpFoundation\RequestMatcher\IpsRequestMatcher;
39+
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
40+
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
41+
use Symfony\Component\HttpFoundation\RequestMatcher\PathRequestMatcher;
42+
use Symfony\Component\HttpFoundation\RequestMatcher\PortRequestMatcher;
43+
use Symfony\Component\HttpFoundation\RequestMatcher\SchemeRequestMatcher;
44+
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
45+
use Symfony\Component\HttpFoundation\Response;
46+
use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface;
47+
use Symfony\Component\RemoteEvent\RemoteEvent;
48+
use Symfony\Component\Webhook\Client\AbstractRequestParser;
49+
use Symfony\Component\Webhook\Exception\RejectWebhookException;
50+
use Symfony\Component\Yaml\Yaml;
51+
52+
/**
53+
* @author Maelan LE BORGNE <[email protected]>
54+
*/
55+
final class MakeWebhook extends AbstractMaker implements InputAwareMakerInterface
56+
{
57+
58+
private const WEBHOOK_CONFIG_PATH = 'config/packages/webhook.yaml';
59+
private YamlSourceManipulator $ysm;
60+
61+
public function __construct(
62+
private FileManager $fileManager,
63+
private Generator $generator,
64+
) {
65+
}
66+
67+
public static function getCommandName(): string
68+
{
69+
return 'make:webhook';
70+
}
71+
72+
public static function getCommandDescription(): string
73+
{
74+
return 'Create a new Webhook';
75+
}
76+
77+
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
78+
{
79+
$command
80+
->addArgument('name', InputArgument::OPTIONAL, sprintf('Name of the webhook to create (e.g. <fg=yellow>github, stripe, ...</>)'))
81+
->setHelp(file_get_contents(__DIR__ . '/../Resources/help/MakeWebhook.txt'))
82+
;
83+
84+
$inputConfig->setArgumentAsNonInteractive('name');
85+
}
86+
87+
public function configureDependencies(DependencyBuilder $dependencies, ?InputInterface $input = null)
88+
{
89+
$dependencies->addClassDependency(
90+
AbstractRequestParser::class,
91+
'webhook'
92+
);
93+
$dependencies->addClassDependency(
94+
ConsumerInterface::class,
95+
'remote-event'
96+
);
97+
$dependencies->addClassDependency(
98+
Yaml::class,
99+
'yaml'
100+
);
101+
}
102+
103+
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
104+
{
105+
if ($input->getArgument('name')) {
106+
if (!$this->verifyWebhookName($input->getArgument('name'))) {
107+
throw new \InvalidArgumentException('A webhook name can only have alphanumeric characters, underscores, dots, and dashes.');
108+
}
109+
110+
return;
111+
}
112+
113+
$argument = $command->getDefinition()->getArgument('name');
114+
$question = new Question($argument->getDescription());
115+
$question->setValidator(Validator::notBlank(...));
116+
$webhookName = $io->askQuestion($question);
117+
118+
while (!$this->verifyWebhookName($webhookName)) {
119+
$io->error('A webhook name can only have alphanumeric characters, underscores, dots, and dashes.');
120+
$webhookName = $io->askQuestion($question);
121+
}
122+
123+
$input->setArgument('name', $webhookName);
124+
}
125+
126+
private function verifyWebhookName(string $entityName): bool
127+
{
128+
return preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_.\-\x7f-\xff]*$/', $entityName);
129+
}
130+
131+
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
132+
{
133+
$webhookName = $input->getArgument('name');
134+
$requestParserDetails = $this->generator->createClassNameDetails(
135+
Str::asClassName($webhookName . 'RequestParser'),
136+
'Webhook\\'
137+
);
138+
$remoteEventHandlerDetails = $this->generator->createClassNameDetails(
139+
Str::asClassName($webhookName . 'WebhookHandler'),
140+
'RemoteEvent\\'
141+
);
142+
143+
$this->addToYamlConfig($webhookName, $requestParserDetails);
144+
145+
$this->generateRequestParser($io, $requestParserDetails);
146+
147+
$this->generator->generateClass(
148+
$remoteEventHandlerDetails->getFullName(),
149+
'webhook/WebhookHandler.tpl.php',
150+
[
151+
'webhook_name' => $webhookName,
152+
]
153+
);
154+
155+
$this->generator->writeChanges();
156+
$this->fileManager->dumpFile(self::WEBHOOK_CONFIG_PATH, $this->ysm->getContents());
157+
}
158+
159+
private function addToYamlConfig(string $webhookName, ClassNameDetails $requestParserDetails): void
160+
{
161+
if (!$this->fileManager->fileExists(self::WEBHOOK_CONFIG_PATH)) {
162+
$yamlConfig = Yaml::dump(['framework' => ['webhook' => ['routing' => []]]], 4, 2);
163+
} else {
164+
$yamlConfig = $this->fileManager->getFileContents(self::WEBHOOK_CONFIG_PATH);
165+
}
166+
$this->ysm = new YamlSourceManipulator($yamlConfig);
167+
$arrayConfig = $this->ysm->getData();
168+
if (key_exists($webhookName, $arrayConfig['framework']['webhook']['routing'] ?? [])) {
169+
throw new \InvalidArgumentException('A webhook with this name already exists');
170+
}
171+
172+
$arrayConfig['framework']['webhook']['routing'][$webhookName] = [
173+
'service' => $requestParserDetails->getFullName(),
174+
'secret' => 'your_secret_here',
175+
];
176+
$this->ysm->setData(
177+
$arrayConfig
178+
);
179+
}
180+
181+
/**
182+
* @param ConsoleStyle $io
183+
* @param ClassNameDetails $requestParserDetails
184+
* @return void
185+
* @throws \Exception
186+
*/
187+
public function generateRequestParser(ConsoleStyle $io, ClassNameDetails $requestParserDetails): void
188+
{
189+
$useStatements = new UseStatementGenerator([
190+
JsonException::class,
191+
Request::class,
192+
Response::class,
193+
RemoteEvent::class,
194+
AbstractRequestParser::class,
195+
RejectWebhookException::class,
196+
RequestMatcherInterface::class,
197+
]);
198+
$requestMatchers = [];
199+
while (true) {
200+
$newRequestMatcher = $this->askForNextRequestMatcher($io, $requestMatchers, $requestParserDetails->getFullName(), empty($requestMatchers));
201+
if (null === $newRequestMatcher) {
202+
break;
203+
}
204+
$requestMatchers[] = $newRequestMatcher;
205+
}
206+
$useChainRequestsMatcher = false;
207+
// Use a ChainRequestMatcher if multiple matchers have been added OR if none (will be printed with an empty array)
208+
if (1 !== \count($requestMatchers)) {
209+
$useChainRequestsMatcher = true;
210+
$useStatements->addUseStatement(ChainRequestMatcher::class);
211+
}
212+
$requestMatcherArguments = [];
213+
foreach ($requestMatchers as $requestMatcherClass) {
214+
$useStatements->addUseStatement($requestMatcherClass);
215+
$requestMatcherArguments[$requestMatcherClass] = $this->getRequestMatcherArguments($requestMatcherClass);
216+
if (ExpressionRequestMatcher::class === $requestMatcherClass) {
217+
if (!class_exists(Expression::class)) {
218+
throw new \Exception('The ExpressionRequestMatcher requires the symfony/expression-language package.');
219+
}
220+
$useStatements->addUseStatement(Expression::class);
221+
$useStatements->addUseStatement(ExpressionLanguage::class);
222+
}
223+
}
224+
225+
$this->generator->generateClass(
226+
$requestParserDetails->getFullName(),
227+
'webhook/RequestParser.tpl.php',
228+
[
229+
'use_statements' => $useStatements,
230+
'use_chained_requests_matcher' => $useChainRequestsMatcher,
231+
'request_matchers' => $requestMatchers,
232+
'request_matcher_arguments' => $requestMatcherArguments,
233+
]
234+
);
235+
}
236+
237+
private function askForNextRequestMatcher(ConsoleStyle $io, array $addedMatchers, string $entityClass, bool $isFirstMatcher): string|null
238+
{
239+
$io->writeln('');
240+
$availableMatchers = $this->getAvailableRequestMatchers();
241+
$matcherName = null;
242+
while (null === $matcherName) {
243+
if ($isFirstMatcher) {
244+
$questionText = 'Add a RequestMatcher (press <return> to skip this step)';
245+
} else {
246+
$questionText = 'Add another RequestMatcher? Enter the RequestMatcher name (or press <return> to stop adding matchers)';
247+
}
248+
249+
$choices = array_diff($availableMatchers, $addedMatchers);
250+
$question = new ChoiceQuestion($questionText, array_values(['<skip>'] + $choices), 0);
251+
$matcherName = $io->askQuestion($question);
252+
if ('<skip>' === $matcherName) {
253+
return null;
254+
}
255+
}
256+
return $matcherName;
257+
}
258+
259+
private function getAvailableRequestMatchers(): array
260+
{
261+
return [
262+
AttributesRequestMatcher::class,
263+
ExpressionRequestMatcher::class,
264+
HostRequestMatcher::class,
265+
IpsRequestMatcher::class,
266+
IsJsonRequestMatcher::class,
267+
MethodRequestMatcher::class,
268+
PathRequestMatcher::class,
269+
PortRequestMatcher::class,
270+
SchemeRequestMatcher::class,
271+
];
272+
}
273+
274+
private function getRequestMatcherArguments(string $requestMatcherClass)
275+
{
276+
return match ($requestMatcherClass) {
277+
AttributesRequestMatcher::class => '[\'attributeName\' => \'regex\']',
278+
ExpressionRequestMatcher::class => 'new ExpressionLanguage(), new Expression(\'expression\')',
279+
HostRequestMatcher::class, PathRequestMatcher::class => '\'regex\'',
280+
IpsRequestMatcher::class => '[\'127.0.0.1\']',
281+
IsJsonRequestMatcher::class => '',
282+
MethodRequestMatcher::class => '\'POST\'',
283+
PortRequestMatcher::class => '443',
284+
SchemeRequestMatcher::class => 'https',
285+
default => '[]',
286+
};
287+
}
288+
}

src/Resources/config/makers.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,5 +152,11 @@
152152
<argument type="service" id="maker.security_controller_builder" />
153153
<tag name="maker.command" />
154154
</service>
155+
156+
<service id="maker.maker.make_webhook" class="Symfony\Bundle\MakerBundle\Maker\MakeWebhook">
157+
<argument type="service" id="maker.file_manager" />
158+
<argument type="service" id="maker.generator" />
159+
<tag name="maker.command" />
160+
</service>
155161
</services>
156162
</container>

src/Resources/help/MakeWebhook.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
The <info>%command.name%</info> command creates a RequestParser, a WebhookHandler and adds the necessary configuration
2+
for a new Webhook.
3+
4+
<info>php %command.full_name% stripe</info>
5+
6+
If the argument is missing, the command will ask for the webhook name interactively.
7+
8+
It will also interactively ask for the RequestMatchers to use for the RequestParser's getRequestMatcher function.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?= "<?php\n" ?>
2+
3+
namespace <?= $namespace; ?>;
4+
5+
<?= $use_statements; ?>
6+
7+
final class <?= $class_name ?> extends AbstractRequestParser
8+
{
9+
protected function getRequestMatcher(): RequestMatcherInterface
10+
{
11+
<?php if ($use_chained_requests_matcher) : ?>
12+
return new ChainRequestMatcher([
13+
<?= empty($request_matchers) ? '// Add RequestMatchers to fit your needs' : '' ?>
14+
15+
<?php foreach ($request_matchers as $request_matcher) : ?>
16+
new <?= Symfony\Bundle\MakerBundle\Str::getShortClassName($request_matcher) ?>(<?= $request_matcher_arguments[$request_matcher] ?>),
17+
<?php endforeach; ?>
18+
]);
19+
<?php else : ?>
20+
return new <?= Symfony\Bundle\MakerBundle\Str::getShortClassName($request_matchers[0]) ?>(<?= $request_matcher_arguments[$request_matchers[0]] ?>);
21+
<?php endif; ?>
22+
}
23+
24+
/**
25+
* @throws JsonException
26+
*/
27+
protected function doParse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent
28+
{
29+
// Implement your own logic to validate and parse the request, and return a RemoteEvent object.
30+
31+
// Validate the request against $secret.
32+
$authToken = $request->headers->get('X-Authentication-Token');
33+
if (is_null($authToken) || $authToken !== $secret) {
34+
throw new RejectWebhookException(Response::HTTP_UNAUTHORIZED, 'Invalid authentication token.');
35+
}
36+
37+
// Validate the request payload.
38+
if (!$request->getPayload()->has('name')
39+
|| !$request->getPayload()->has('id')) {
40+
throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Request payload does not contain required fields.');
41+
}
42+
43+
// Parse the request payload and return a RemoteEvent object.
44+
$payload = $request->getPayload()->getIterator()->getArrayCopy();
45+
46+
return new RemoteEvent(
47+
$payload['name'],
48+
$payload['id'],
49+
$payload,
50+
);
51+
}
52+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?= "<?php\n" ?>
2+
3+
namespace <?= $namespace; ?>;
4+
5+
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
6+
use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface;
7+
use Symfony\Component\RemoteEvent\RemoteEvent;
8+
9+
#[AsRemoteEventConsumer('<?= $webhook_name ?>')]
10+
final class <?= $class_name ?> implements ConsumerInterface
11+
{
12+
public function __construct()
13+
{
14+
}
15+
16+
public function consume(RemoteEvent $event): void
17+
{
18+
// Implement your own logic here
19+
}
20+
}

0 commit comments

Comments
 (0)