Skip to content

Commit 35ef9e0

Browse files
feature #1491 [make:webhook] Add new command for Symfony's Webhook Component
Co-authored-by: Jesse Rushlow <[email protected]>
1 parent 30cdf17 commit 35ef9e0

File tree

13 files changed

+810
-2
lines changed

13 files changed

+810
-2
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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\Common;
13+
14+
use Symfony\Bundle\MakerBundle\ConsoleStyle;
15+
use Symfony\Component\Process\Process;
16+
17+
trait InstallDependencyTrait
18+
{
19+
/**
20+
* @param string $composerPackage Fully qualified composer package to install e.g. symfony/maker-bundle
21+
*/
22+
public function installDependencyIfNeeded(ConsoleStyle $io, string $expectedClassToExist, string $composerPackage): ConsoleStyle
23+
{
24+
if (class_exists($expectedClassToExist)) {
25+
return $io;
26+
}
27+
28+
$io->writeln(sprintf('Running: composer require %s', $composerPackage));
29+
30+
Process::fromShellCommandline(sprintf('composer require %s', $composerPackage))->run();
31+
32+
$io->writeln(sprintf('%s successfully installed!', $composerPackage));
33+
$io->newLine();
34+
35+
return $io;
36+
}
37+
}

src/Maker/MakeWebhook.php

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

src/Resources/config/makers.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,5 +157,11 @@
157157
<argument type="service" id="maker.security_controller_builder" />
158158
<tag name="maker.command" />
159159
</service>
160+
161+
<service id="maker.maker.make_webhook" class="Symfony\Bundle\MakerBundle\Maker\MakeWebhook">
162+
<argument type="service" id="maker.file_manager" />
163+
<argument type="service" id="maker.generator" />
164+
<tag name="maker.command" />
165+
</service>
160166
</services>
161167
</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.

0 commit comments

Comments
 (0)