From f247fb692e388e9b599055f5d1d9054d08b8492a Mon Sep 17 00:00:00 2001 From: Marco Stoll Date: Mon, 24 Jun 2019 09:44:09 +0200 Subject: [PATCH 1/2] [FEATURE] add templating interface and twig --- composer.json | 7 +- src/Events/Templating/PostRender.php | 43 ++++ src/Events/Templating/PreRender.php | 58 ++++++ .../Exceptions/RenderingException.php | 21 ++ src/Services/Templating/RenderedDocument.php | 50 +++++ .../Templating/TemplateRendererInterface.php | 33 ++++ src/Services/Templating/TwigRenderer.php | 186 ++++++++++++++++++ .../Services/Templating/TwigRendererTest.php | 181 +++++++++++++++++ .../Templating/templates/basic.html.twig | 1 + .../Templating/templates/invalid.html.twig | 2 + 10 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 src/Events/Templating/PostRender.php create mode 100644 src/Events/Templating/PreRender.php create mode 100644 src/Services/Templating/Exceptions/RenderingException.php create mode 100644 src/Services/Templating/RenderedDocument.php create mode 100644 src/Services/Templating/TemplateRendererInterface.php create mode 100644 src/Services/Templating/TwigRenderer.php create mode 100644 tests/Services/Templating/TwigRendererTest.php create mode 100644 tests/Services/Templating/templates/basic.html.twig create mode 100644 tests/Services/Templating/templates/invalid.html.twig diff --git a/composer.json b/composer.json index 97a09de..9ffc09e 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,12 @@ "require": { "php": ">=7.2", "fastforward/data-structures": "^1.0.0", - "fastforward/factories": "^1.2.0" + "fastforward/factories": "^1.2.0", + "symfony/config": "~4.3", + "symfony/http-foundation": "~4.3", + "symfony/routing": "~4.3", + "symfony/yaml": "~4.3", + "twig/twig": "^2.0" }, "require-dev": { "phpunit/phpunit": "^8" diff --git a/src/Events/Templating/PostRender.php b/src/Events/Templating/PostRender.php new file mode 100644 index 0000000..d2bbd74 --- /dev/null +++ b/src/Events/Templating/PostRender.php @@ -0,0 +1,43 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Events\Templating; + +use FF\Events\AbstractEvent; +use FF\Services\Templating\RenderedDocument; + +/** + * Class PostRender + * + * @package FF\Events\Templating + */ +class PostRender extends AbstractEvent +{ + /** + * @var RenderedDocument + */ + protected $doc; + + /** + * @param RenderedDocument $doc + */ + public function __construct(RenderedDocument $doc) + { + $this->doc = $doc; + } + + /** + * @return RenderedDocument + */ + public function getDoc(): RenderedDocument + { + return $this->doc; + } +} \ No newline at end of file diff --git a/src/Events/Templating/PreRender.php b/src/Events/Templating/PreRender.php new file mode 100644 index 0000000..d868b46 --- /dev/null +++ b/src/Events/Templating/PreRender.php @@ -0,0 +1,58 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Events\Templating; + +use FF\DataStructures\Record; +use FF\Events\AbstractEvent; + +/** + * Class PreForward + * + * @package FF\Events\Templating + */ +class PreRender extends AbstractEvent +{ + /** + * @var string + */ + protected $template; + + /** + * @var Record + */ + protected $data; + + /** + * @param string $template + * @param Record $data + */ + public function __construct(string $template, Record $data) + { + $this->template = $template; + $this->data = $data; + } + + /** + * @return string + */ + public function getTemplate(): string + { + return $this->template; + } + + /** + * @return Record + */ + public function getData(): Record + { + return $this->data; + } +} \ No newline at end of file diff --git a/src/Services/Templating/Exceptions/RenderingException.php b/src/Services/Templating/Exceptions/RenderingException.php new file mode 100644 index 0000000..b25b15b --- /dev/null +++ b/src/Services/Templating/Exceptions/RenderingException.php @@ -0,0 +1,21 @@ + +* @copyright 2019-forever Marco Stoll +* @filesource +*/ +declare(strict_types=1); + +namespace FF\Services\Templating\Exceptions; + +/** +* Class RenderingException +* +* @package FF\Services\Templating\Exceptions +*/ +class RenderingException extends \RuntimeException +{ + +} \ No newline at end of file diff --git a/src/Services/Templating/RenderedDocument.php b/src/Services/Templating/RenderedDocument.php new file mode 100644 index 0000000..0e84bf0 --- /dev/null +++ b/src/Services/Templating/RenderedDocument.php @@ -0,0 +1,50 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Services\Templating; + +/** + * Class RenderedDocument + * + * @package FF\Services\Templating + */ +class RenderedDocument +{ + /** + * @var string + */ + private $contents; + + /** + * @param string $contents + */ + public function __construct(string $contents) + { + $this->contents = $contents; + } + + /** + * @return string + */ + public function getContents(): string + { + return $this->contents; + } + + /** + * @param string $contents + * @return $this + */ + public function setContents(string $contents) + { + $this->contents = $contents; + return $this; + } +} diff --git a/src/Services/Templating/TemplateRendererInterface.php b/src/Services/Templating/TemplateRendererInterface.php new file mode 100644 index 0000000..493dcca --- /dev/null +++ b/src/Services/Templating/TemplateRendererInterface.php @@ -0,0 +1,33 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Services\Templating; + +use FF\Services\Templating\Exceptions\RenderingException; + +/** + * Interface TemplateRendererInterface + * + * @package FF\Services\Templating + */ +interface TemplateRendererInterface +{ + /** + * Renders a template using the given data + * + * @param string $template + * @param array $data + * @return string + * @throws RenderingException + * @fires Templating\PreRender + * @fires Templating\PostRender + */ + public function render(string $template, array $data): string; +} diff --git a/src/Services/Templating/TwigRenderer.php b/src/Services/Templating/TwigRenderer.php new file mode 100644 index 0000000..ada0534 --- /dev/null +++ b/src/Services/Templating/TwigRenderer.php @@ -0,0 +1,186 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ + +declare(strict_types=1); + +namespace FF\Services\Templating; + +use FF\DataStructures\Record; +use FF\Services\AbstractService; +use FF\Services\Templating\Exceptions\RenderingException; +use Twig\Environment; +use Twig\Error\Error; +use Twig\Extension\ExtensionInterface; +use Twig\Loader\FilesystemLoader; +use Twig\NodeVisitor\NodeVisitorInterface; +use Twig\RuntimeLoader\RuntimeLoaderInterface; +use Twig\TokenParser\TokenParserInterface; +use Twig\TwigFilter; +use Twig\TwigFunction; +use Twig\TwigTest; + +/** + * Class TwigRenderer + * + * Options: + * + * - twig-options : array (default: []) - options to configure twig environment + * - template-dir : string - path to templates folder + * - fire-events : bool (default: false) - whether to fire rendering events + * + * @method void addRuntimeLoader(RuntimeLoaderInterface $loader) + * @method void addExtension(ExtensionInterface $extension) + * @method void addTokenParser(TokenParserInterface $parser) + * @method void addNodeVisitor(NodeVisitorInterface $visitor) + * @method void addFilter(TwigFilter $filter) + * @method void addTest(TwigTest $test) + * @method void addFunction(TwigFunction $function) + * @method void addGlobal(string $name, mixed $value) + * + * @package FF\Services\Templating + * + * @link https://twig.symfony.com/doc/2.x/api.html#environment-options Twig Environment options + * @link https://twig.symfony.com/doc/2.x/advanced.html Extending Twig + */ +class TwigRenderer extends AbstractService implements TemplateRendererInterface +{ + /** + * @var Environment + */ + protected $twig; + + /** + * @var bool + */ + protected $fireEvents; + + /** + * @return Environment + */ + public function getTwig(): Environment + { + return $this->twig; + } + + /** + * @param Environment $twig + * @return $this + */ + public function setTwig(Environment $twig) + { + $this->twig = $twig; + return $this; + } + + /** + * @return bool + */ + public function getFireEvents(): bool + { + return $this->fireEvents; + } + + /** + * @param bool $fireEvents + * @return $this + */ + public function setFireEvents(bool $fireEvents) + { + $this->fireEvents = $fireEvents; + return $this; + } + + /** + * Renders a template using the given data + * + * @param string $template + * @param array $data + * @return string + * @throws RenderingException + * @fires Templating\PreRender + * @fires Templating\PostRender + */ + public function render(string $template, array $data): string + { + if ($this->fireEvents) { + // wrap data in object to support data manipulation via event listener + $record = new Record($data); + $this->fire('Templating\PreRender', $template, $record); + $data = $record->getDataAsArray(); + } + + try { + $contents = $this->twig->render($template, $data); + } catch (Error $e) { + throw new RenderingException($e->getMessage(), $e->getCode(), $e); + } + + if ($this->fireEvents) { + // wrap data in object to support data manipulation via event listener + $doc = new RenderedDocument($contents); + $this->fire('Templating\PostRender', $doc); + $contents = $doc->getContents(); + } + + return $contents; + } + + /** + * Magic proxy for the public api of Twig\Environment + * + * Routes the method call to the twig environment instance encapsulated + * within this service. + * + * @param string $name + * @param array $arguments + * @return mixed + * @throws \BadMethodCallException Method is not defined or accessible on Twig\Environment + */ + public function __call(string $name, array $arguments = []) + { + $callable = [$this->twig, $name]; + if (!is_callable($callable)) { + // trigger fatal error: unsupported method call + // mimic standard php error message + // Fatal error: Call to undefined method {class}::{method}() in {file} on line {line} + $backTrace = debug_backtrace(); + $errorMsg = 'Call to undefined method ' . __CLASS__ . '::' . $name . '() ' + . 'in ' . $backTrace[0]['file'] . ' on line ' . $backTrace[0]['line']; + trigger_error($errorMsg, E_USER_ERROR); + } + + return call_user_func_array($callable, $arguments); + } + + /** + * {@inheritDoc} + */ + protected function initialize(array $options) + { + parent::initialize($options); + + $this->fireEvents = $this->getOption('fire-events', false); + + $loader = new FilesystemLoader($this->getOption('template-dir')); + $this->twig = new Environment($loader, $this->getOption('twig-options', [])); + } + + /** + * {@inheritDoc} + */ + protected function validateOptions(array $options, array &$errors): bool + { + if (!isset($options['template-dir']) || empty($options['template-dir'])) { + $errors[] = 'missing or empty mandatory option [template-dir]'; + return false; + } + + return true; + } +} diff --git a/tests/Services/Templating/TwigRendererTest.php b/tests/Services/Templating/TwigRendererTest.php new file mode 100644 index 0000000..5d6e378 --- /dev/null +++ b/tests/Services/Templating/TwigRendererTest.php @@ -0,0 +1,181 @@ + + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Tests\Services\Templating; + +use FF\Events\AbstractEvent; +use FF\Events\Templating\PostRender; +use FF\Events\Templating\PreRender; +use FF\Factories\ServicesFactory; +use FF\Factories\SF; +use FF\Services\Events\EventBroker; +use FF\Services\Exceptions\ConfigurationException; +use FF\Services\Templating\Exceptions\RenderingException; +use FF\Services\Templating\TwigRenderer; +use PHPUnit\Framework\Error\Error; +use PHPUnit\Framework\TestCase; +use Twig\Environment; +use Twig\Loader\ArrayLoader; + +/** + * Test TwigRendererTest + * + * @package FF\Tests + */ +class TwigRendererTest extends TestCase +{ + const DEFAULT_OPTIONS = [ + 'template-dir' => __DIR__ . '/templates' + ]; + + /** + * @var TwigRenderer + */ + protected $uut; + + /** + * @var AbstractEvent[] + */ + protected static $lastEvents; + + /** + * {@inheritdoc} + */ + public static function setUpBeforeClass(): void + { + SF::setInstance(new ServicesFactory()); + + /** @var EventBroker $eventBroker */ + $eventBroker = SF::i()->get('Events\EventBroker'); + + // register test listener + $eventBroker + ->subscribe([__CLASS__, 'listener'], 'Templating\PreRender') + ->subscribe([__CLASS__, 'listener'], 'Templating\PostRender'); + } + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->uut = new TwigRenderer(self::DEFAULT_OPTIONS); + + self::$lastEvents = []; + } + + /** + * Dummy event listener + * + * @param AbstractEvent $event + */ + public static function listener(AbstractEvent $event) + { + self::$lastEvents[get_class($event)] = $event; + } + + /** + * Tests the namesake method/feature + */ + public function testInitializeErrorMandatoryOptions() + { + $this->expectException(ConfigurationException::class); + + new TwigRenderer(); + } + + /** + * Tests the namesake method/feature + */ + public function testSetGetTwig() + { + $value = new Environment(new ArrayLoader()); + $same = $this->uut->setTwig($value); + $this->assertSame($this->uut, $same); + $this->assertEquals($value, $this->uut->getTwig()); + } + + /** + * Tests the namesake method/feature + */ + public function testSetGetFireEvents() + { + $value = false; + $same = $this->uut->setFireEvents($value); + $this->assertSame($this->uut, $same); + $this->assertEquals($value, $this->uut->getFireEvents()); + } + + /** + * Tests the namesake method/feature + */ + public function testRenderWithoutEvents() + { + $doc = $this->uut->setFireEvents(false) + ->render('basic.html.twig', ['foo' => 'bar']); + $this->assertEquals('foo: bar', $doc); + + $this->assertEmpty(self::$lastEvents); + } + + /** + * Tests the namesake method/feature + */ + public function testRenderWithEvents() + { + $doc = $this->uut->setFireEvents(true) + ->render('basic.html.twig', ['foo' => 'bar']); + $this->assertEquals('foo: bar', $doc); + + $this->assertArrayHasKey(PreRender::class, self::$lastEvents); + $this->assertArrayHasKey(PostRender::class, self::$lastEvents); + } + + /** + * Tests the namesake method/feature + */ + public function testRenderErrorLoader() + { + $this->expectException(RenderingException::class); + + $this->uut->render('missing.html.twig', []); + } + + /** + * Tests the namesake method/feature + */ + public function testRenderErrorSyntax() + { + $this->expectException(RenderingException::class); + + $this->uut->render('invalid.html.twig', []); + } + + /** + * Tests the namesake method/feature + */ + public function testMagicCall() + { + $this->uut->addGlobal('foo', 'bar'); + + $doc = $this->uut->render('basic.html.twig', []); + $this->assertEquals('foo: bar', $doc); + } + + /** + * Tests the namesake method/feature + */ + public function testMagicCallUnknown() + { + $this->expectException(Error::class); + + $this->uut->foo(); + } +} \ No newline at end of file diff --git a/tests/Services/Templating/templates/basic.html.twig b/tests/Services/Templating/templates/basic.html.twig new file mode 100644 index 0000000..13b0563 --- /dev/null +++ b/tests/Services/Templating/templates/basic.html.twig @@ -0,0 +1 @@ +foo: {{ foo }} \ No newline at end of file diff --git a/tests/Services/Templating/templates/invalid.html.twig b/tests/Services/Templating/templates/invalid.html.twig new file mode 100644 index 0000000..a127b5a --- /dev/null +++ b/tests/Services/Templating/templates/invalid.html.twig @@ -0,0 +1,2 @@ +{% for %} +{% endif %} \ No newline at end of file From 853bcb1c66642fadc1c175951c96404550d2641a Mon Sep 17 00:00:00 2001 From: Marco Stoll Date: Mon, 24 Jun 2019 09:48:08 +0200 Subject: [PATCH 2/2] [DOCS] update readme --- readme.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/readme.md b/readme.md index efeb096..4998b01 100644 --- a/readme.md +++ b/readme.md @@ -19,6 +19,7 @@ Currently **FF** is composed of the following features: 1. Services and the Service Factory 2. Events and the Event Broker 3. Runtime event handlers +5. Templating and Twig as a Service More features will follow (see the Road Map section below). @@ -305,6 +306,29 @@ Example: // handle the event data var_dump($event->getErroNo(), $event->getErrMsg()); }}; + +# Templating and Twig as a Service + +This feature provides the `TemplateRendererInterface` defining the basic api for adding concrete template rendering +class. + +## Rendering Events + +The `TemplateRendererInterface` defines that each concrete renderer may fire the following events while performing its +`render()` method: + +- Templating\PreRender : directly before rendering the template +- Templating\PostRender : directly after rendering the template + +Adding observers for this events lets you manipulate the rendering input data as well as the rendering output document +on your behalf. + +## Twig Support +A generic `TwigRenderer` renderer service is provided using a `Twig\FilesystemLoader` to locate templates. + +Consult to learn more about **Twig**. + +The `TwigRenderer` may be configured to fire rendering events if desired. # Road map