Skip to content

Commit

Permalink
Merge pull request symfony#1 from nicolas-grekas/turbo
Browse files Browse the repository at this point in the history
Tweaks and review
  • Loading branch information
dunglas authored Mar 26, 2021
2 parents 6c3ef9e + a1dffea commit 8353ff3
Show file tree
Hide file tree
Showing 17 changed files with 167 additions and 238 deletions.
4 changes: 2 additions & 2 deletions src/Turbo/Broadcast.php → src/Turbo/Attribute/Broadcast.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/

namespace Symfony\UX\Turbo;
namespace Symfony\UX\Turbo\Attribute;

use Symfony\UX\Turbo\Mercure\Broadcaster;

Expand All @@ -35,7 +35,7 @@ final class Broadcast
/**
* Options can be any option supported by the broadcaster.
*
* @see Broadcaster for the default options
* @see Broadcaster for the default options when using Mercure
*/
public function __construct(mixed ...$options)
{
Expand Down
14 changes: 11 additions & 3 deletions src/Turbo/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ public function getConfigTreeBuilder(): TreeBuilder
->arrayNode('broadcast')
->{\PHP_VERSION_ID >= 80000 ? 'canBeDisabled' : 'canBeEnabled'}()
->children()
->scalarNode('entity_namespace')->info('Prefix to strip when looking for broadcast templates')->defaultValue('App\\Entity\\')->end()
->arrayNode('entity_template_prefixes')
->fixXmlConfig('entity_template_prefix')
->defaultValue(['App\Entity\\' => 'broadcast/'])
->scalarPrototype()->end()
->end()
->arrayNode('doctrine_orm')
->info('Enable the Doctrine ORM integration')
->{class_exists(DoctrineBundle::class) && interface_exists(EntityManagerInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}()
Expand All @@ -45,9 +49,13 @@ public function getConfigTreeBuilder(): TreeBuilder
->scalarNode('default_transport')->defaultValue('default')->end()
->arrayNode('mercure')
->{class_exists(MercureBundle::class) ? 'canBeDisabled' : 'canBeEnabled'}()
->info('If the Mercure hubs to use as transports aren\'t configured explicitly, a transport named "default" using the default Mercure hub will be automatically created.')
->info('If no Mercure hubs are configured explicitly, the default Mercure hub will be used.')
->children()
->arrayNode('hubs')->scalarPrototype()->info('The name of the Mercure hubs (configured in MercureBundle) to use as transports')->end()
->arrayNode('hubs')
->fixXmlConfig('hub')
->info('The name of the Mercure hubs (configured in MercureBundle) to use as transports')
->scalarPrototype()->end()
->end()
->end()
->end()
->end()
Expand Down
36 changes: 19 additions & 17 deletions src/Turbo/DependencyInjection/TurboExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Mercure\Hub;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface;
use Symfony\UX\Turbo\Doctrine\BroadcastListener;
use Symfony\UX\Turbo\Mercure\Broadcaster;
use Symfony\UX\Turbo\Mercure\TurboStreamListenRenderer;
use Symfony\UX\Turbo\Twig\TurboStreamListenRendererInterface;
Expand All @@ -46,7 +48,7 @@ public function load(array $configs, ContainerBuilder $container): void
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);

$loader = (new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')));
$loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config')));
$loader->load('services.php');
$container->getDefinition(TwigExtension::class)->replaceArgument(1, $config['default_transport']);

Expand Down Expand Up @@ -80,7 +82,7 @@ private function registerBroadcast(array $config, ContainerBuilder $container, L
}

if (\PHP_VERSION_ID < 80000) {
throw new InvalidConfigurationException('Enabling the "broadcast" configuration option requires PHP 8 or higher.');
throw new InvalidConfigurationException('Enabling the "turbo.broadcast" configuration option requires PHP 8 or higher.');
}

$container
Expand All @@ -89,31 +91,33 @@ private function registerBroadcast(array $config, ContainerBuilder $container, L
;

if (!$config['broadcast']['doctrine_orm']['enabled']) {
$container->removeDefinition(BroadcastListener::class);

return;
}

if (!class_exists(DoctrineBundle::class) || !interface_exists(EntityManagerInterface::class)) {
throw new InvalidConfigurationException('You cannot use the Doctrine ORM integration as the "doctrine/doctrine-bundle" package is not installed. Try running "composer require symfony/orm-pack".');
throw new InvalidConfigurationException('You cannot use the Doctrine ORM integration as the Doctrine bundle is not installed. Try running "composer require symfony/orm-pack".');
}

$loader->load('doctrine_orm.php');
}

/**
* @param array<string, mixed> $config
*/
private function registerMercureTransports(array $config, ContainerBuilder $container, LoaderInterface $loader): void
{
if (!$config['mercure']) {
if (!$config['mercure']['enabled']) {
return;
}

if (!class_exists(MercureBundle::class)) {
throw new InvalidConfigurationException('You cannot use the Mercure integration as the "symfony/mercure-bundle" package is not installed. Try running "composer require symfony/mercure-bundle".');
}
$missingDeps = array_filter([
'symfony/mercure-bundle' => !class_exists(MercureBundle::class),
'symfony/twig-pack' => !class_exists(TwigBundle::class),
'symfony/property-access' => !interface_exists(PropertyAccessorInterface::class),
]);

if (!class_exists(TwigBundle::class)) {
throw new InvalidConfigurationException('You cannot use the Mercure integration as the "symfony/twig-bundle" package is not installed. Try running "composer require symfony/twig-pack".');
if ($missingDeps) {
throw new InvalidConfigurationException(sprintf('You cannot use the Mercure integration as some required dependencies are missing. Try running "composer require %s".', implode(' ', $missingDeps)));
}

$loader->load('mercure.php');
Expand All @@ -135,20 +139,18 @@ private function registerMercureTransports(array $config, ContainerBuilder $cont
*/
private function registerMercureTransport(ContainerBuilder $container, array $config, string $name, string $hubId): void
{
$hubService = new Reference($hubId);

$renderer = $container->setDefinition("turbo.mercure.{$name}.renderer", new ChildDefinition(TurboStreamListenRenderer::class));
$renderer->replaceArgument(0, $hubService);
$renderer->addTag('turbo.renderer.stream_listen', ['key' => $name]);
$renderer->replaceArgument(0, new Reference($hubId));
$renderer->addTag('turbo.renderer.stream_listen', ['index' => $name]);

if (!$config['broadcast']['enabled']) {
return;
}

$broadcaster = $container->setDefinition("turbo.mercure.{$name}.broadcaster", new ChildDefinition(Broadcaster::class));
$broadcaster->replaceArgument(0, $name);
$broadcaster->replaceArgument(2, $hubService);
$broadcaster->replaceArgument(5, $config['broadcast']['entity_namespace']);
$broadcaster->replaceArgument(2, new Reference($hubId));
$broadcaster->replaceArgument(4, $config['broadcast']['entity_template_prefixes']);
$broadcaster->addTag('turbo.broadcaster');
}
}
23 changes: 14 additions & 9 deletions src/Turbo/Doctrine/BroadcastListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,36 +14,37 @@
use Doctrine\Common\EventArgs;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Symfony\Contracts\Service\ResetInterface;
use Symfony\UX\Turbo\Broadcast;
use Symfony\UX\Turbo\Attribute\Broadcast;
use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface;

/**
* Detects changes made from Doctrine entities and broadcasts updates to the Mercure hub.
*
* @author Kévin Dunglas <[email protected]>
*
* @see https://github.com/api-platform/core/blob/master/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php Adapted from API Platform.
*
* @todo backport MongoDB support
*
* @experimental
*/
final class BroadcastListener implements ResetInterface
{
private $broadcaster;

/**
* @var array<class-string, \ReflectionAttribute[]>
*/
private $broadcastedClasses;

/**
* @var \SplObjectStorage<object, object>
*/
private \SplObjectStorage $createdEntities;
private $createdEntities;
/**
* @var \SplObjectStorage<object, object>
*/
private \SplObjectStorage $updatedEntities;
private $updatedEntities;
/**
* @var \SplObjectStorage<object, object>
*/
private \SplObjectStorage $removedEntities;
private $removedEntities;

public function __construct(BroadcasterInterface $broadcaster)
{
Expand Down Expand Up @@ -110,7 +111,11 @@ public function reset(): void

private function storeEntitiesToPublish(object $entity, string $property): void
{
if ((new \ReflectionClass($entity))->getAttributes(Broadcast::class)) {
$class = \get_class($entity);

$this->broadcastedClasses[$class] ?? $this->broadcastedClasses[$class] = (new \ReflectionClass($class))->getAttributes(Broadcast::class);

if ($this->broadcastedClasses[$class]) {
$this->{$property}->attach('removedEntities' === $property ? clone $entity : $entity);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Turbo/LICENSE
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2021 Kévin Dunglas
Copyright (c) 2021 Fabien Potencier

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
71 changes: 24 additions & 47 deletions src/Turbo/Mercure/Broadcaster.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
use Symfony\Component\Mercure\Update;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Security\Core\Authorization\ExpressionLanguage;
use Symfony\UX\Turbo\Broadcast;
use Symfony\UX\Turbo\Attribute\Broadcast;
use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface;
use Twig\Environment;

Expand All @@ -25,16 +24,13 @@
*
* Supported options are:
*
* * topics (string[]) Mercure topics to use, defaults to an array containing the Fully Qualified Class Name with "\" characters replaced by ":" characters.
* * createTemplate (string) The Twig template to render when a new object is created
* * updateTemplate (string) The Twig template to render when a new object is updated
* * removeTemplate (string) The Twig template to render when a new object is removed
* * private (bool) Marks Mercure updates as private
* * id (string) ID field of the SSE
* * type (string) type field of the SSE
* * retry (int) retry field of the SSE
*
* The options can also be generated using the ExpressionLanguage language: if the option is a string, it is evaluated as an expression that must return an array.
* * transports (string[]) The name of the transports to broadcast to
* * topics (string[]) The topics to use; the default topic is derived from the FQCN of the entity and from its id
* * template (string) The Twig template to render when a new object is created, updated or removed
* * private (bool) Marks Mercure updates as private
* * id (string) ID field of the SSE
* * type (string) type field of the SSE
* * retry (int) retry field of the SSE
*
* @author Kévin Dunglas <[email protected]>
*
Expand All @@ -51,12 +47,7 @@ final class Broadcaster implements BroadcasterInterface
private $twig;
private $hub;
private $propertyAccessor;
private $entityNamespace;

/**
* @var ExpressionLanguage|null
*/
private $expressionLanguage;
private $templatePrefixes;

private const OPTIONS = [
// Generic options
Expand All @@ -71,14 +62,11 @@ final class Broadcaster implements BroadcasterInterface
'retry',
];

public function __construct(
string $name,
Environment $twig,
HubInterface $hub,
?PropertyAccessorInterface $propertyAccessor,
?ExpressionLanguage $expressionLanguage = null,
?string $entityNamespace = null
) {
/**
* @param array<string, string> $templatePrefixes
*/
public function __construct(string $name, Environment $twig, HubInterface $hub, ?PropertyAccessorInterface $propertyAccessor, array $templatePrefixes = [])
{
if (80000 > \PHP_VERSION_ID) {
throw new \LogicException('The broadcast feature requires PHP 8.0 or greater, you must either upgrade to PHP 8 or disable it.');
}
Expand All @@ -87,13 +75,7 @@ public function __construct(
$this->twig = $twig;
$this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
$this->hub = $hub;
$this->entityNamespace = $entityNamespace;

if ($expressionLanguage) {
$this->expressionLanguage = $expressionLanguage;
} elseif (class_exists(ExpressionLanguage::class)) {
$this->expressionLanguage = new ExpressionLanguage();
}
$this->templatePrefixes = $templatePrefixes;
}

public function broadcast(object $entity, string $action): void
Expand All @@ -108,11 +90,11 @@ public function broadcast(object $entity, string $action): void
$broadcast = $attribute->newInstance();
$options = $this->normalizeOptions($entity, $action, $broadcast->options);

if (isset($options['transports']) && !\in_array($this->name, $options['transports'], false)) {
if (isset($options['transports']) && !\in_array($this->name, $options['transports'], true)) {
return;
}

// What must we do if the template or the block doesn't exist? Throwing for now.
// Will throw if the template or the block doesn't exist
$data = $this->twig->load($options['template'])->renderBlock($action, ['entity' => $entity, 'action' => $action, 'options' => $options]);

$update = new Update(
Expand All @@ -138,14 +120,6 @@ private function normalizeOptions(object $entity, string $action, array $options
$options['transports'] = (array) $options['transports'];
}

if (\is_string($options[0] ?? null)) {
if (null === $this->expressionLanguage) {
throw new \RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".');
}

$options = $this->expressionLanguage->evaluate($options[0], ['entity' => $entity, 'action' => $action]);
}

$entityClass = \get_class($entity);

if ($extraKeys = array_diff(array_keys($options), self::OPTIONS)) {
Expand All @@ -157,12 +131,15 @@ private function normalizeOptions(object $entity, string $action, array $options
return $options;
}

$dir = $entityClass;
if ($this->entityNamespace && 0 === strpos($entityClass, $this->entityNamespace)) {
$dir = substr($entityClass, \strlen($this->entityNamespace));
$file = $entityClass;
foreach ($this->templatePrefixes as $namespace => $prefix) {
if (0 === strpos($entityClass, $namespace)) {
$file = substr_replace($entityClass, $prefix, 0, \strlen($namespace));
break;
}
}

$options['template'] = sprintf('broadcast/%s.stream.html.twig', str_replace('\\', '/', $dir));
$options['template'] = str_replace('\\', '/', $file).'.stream.html.twig';

return $options;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Turbo/Mercure/TurboStreamListenRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public function renderTurboStreamListen(Environment $env, $topic): string
{
if (\is_object($topic)) {
$topic = sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode(\get_class($topic)), rawurlencode($this->propertyAccessor->getValue($topic, 'id')));
} elseif (class_exists($topic)) {
} elseif (!preg_match('/[^a-zA-Z0-9_\x7f-\xff\\\\]/', $topic) && class_exists($topic)) {
// Generate a URI template to subscribe to updates for all objects of this class
$topic = sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($topic), '{id}');
}
Expand Down
Loading

0 comments on commit 8353ff3

Please sign in to comment.