Skip to content
This repository has been archived by the owner on Jan 21, 2020. It is now read-only.

Provide stateless template variable aggregation #70

Conversation

weierophinney
Copy link
Member

TemplateRendererInterface::addDefaultParam() has side-effects, and should not be used in async environments unless setting template variables that will be used on every request.

As such, this patch introduces an alternative workflow for aggregating pipeline-specific template variables. It provides two classes:

  • Zend\Expressive\Helper\Template\TemplateVariableContainer, which provides a way to aggregate template variables, as well as merge local variables for the purpose of producing a set to pass to the template renderer.
  • Zend\Expressive\Helper\Template\TemplateVariableContainerMiddleware, which provides a way to register the container as a request attribute within your pipeline.

Middleware can then set template parameters using either the container's set() or merge() methods:

$request->getAttribute(TemplateVariableContainer::class)
   ->set('user', $user);

$request->getAttribute(TemplateVariableContainer::class)
   ->merge([
       'user'  => $user,
       'route' => $route,
   );

Handlers can then use the mergeForTemplate() method to prepare variables to pass to the renderer:

$content = $this->renderer->render(
   'some::template',
   $container->mergeForTemplate([
       'handler-specific-variable' => $value,
   ])
);

Additionally, this patch provides Zend\Expressive\Helper\Template\RouteTemplateVariableMiddleware. This middleware works in conjunction with the TemplateVariableContainerMiddleware, and inspects the request for a Zend\Expressive\Router\RouteResult instance. If found, it injects the value returned by getMatchedRoute() under the name route in the TemplateVariableContainer, allowing templates access to it via the variable $route. This value will either be empty, or a Zend\Expressive\Router\Route instance.

The RouteTemplateVariableMiddleware can be used in place of the UrlHelperMiddleware, particularly if you do not use the UrlHelper without a route name.

@weierophinney
Copy link
Member Author

Ping @ezimuel and @nuxwin; these features allow aggregating any pipeline-specific template variables such that state will not persist in the selected template renderer, making it a safe approach for applications using async dispatchers (e.g., Swoole). Please review!

@nuxwin
Copy link

nuxwin commented Feb 27, 2019

@weierophinney

Sure ;) I'll test that after eating and give you my feedback ;)

You're work is much appreciated.

weierophinney added a commit to weierophinney/zend-expressive-helpers that referenced this pull request Feb 27, 2019
@@ -456,6 +463,138 @@ $app->get('/download/tarball', [
], 'download-tar');
```

### Template Variable Container
Copy link
Member

@froschdesign froschdesign Feb 27, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That should not be in the README file and must be moved to the documentation. The informations in the README and the documentation are already different for this topic.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@froschdesign I'll be doing a separate PR to the zend-expressive repo's documentation once this is merged and released. Unfortunately, we have the docs for this package in that repo, so we need to have something in the README here as well.

I'd like to eventually move some of the repo-specific docs out of the zend-expressive docs, but I'm not 100% sure how we can do that while keeping the information findable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@weierophinney

Unfortunately, we have the docs for this package in that repo, so we need to have something in the README here as well.

Keep it simple: Add a link to the documentation to the README file - as everywhere. In this particular case, I would also add a short note that the informations can be found in the zend-expressive docs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@froschdesign Noted.

I'll keep it as-is for now, and then, after the release, submit a PR to the zend-expressive repo with documentation. After that is merged, I'll send another PR here to remove the verbiage and link to the official docs.

Copy link

@nuxwin nuxwin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be a merge recursive?

*/
public function mergeForTemplate(array $values) : array
{
return array_merge($this->variables, $values);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be a recursive merge?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure if there was a good use case for that. Generally speaking, you assign discrete values to templates: scalars, possibly objects, occasionally arrays. I'm not sure it would make sense to do a recursive merge for arrays, as you would generally be replacing the value at the top-level assignment.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@weierophinney You're right ;)

BTW: I would want require your branch through composer with commit ref but it seem I cannot access it. I miss something?

Copy link
Member Author

@weierophinney weierophinney Feb 27, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because the patch is submitted via my fork, and not as a branch on this repo (I submit PRs the same as anybody else does! 😄). As such, you will need to add a repositories section to your composer.json:

"repositories": [
  {"type": "vcs", "url": "https://github.com/weierophinney/zend-expressive-helpers.git"}
]

Then, where you require zend-expressive-helpers in your composer.json, update it as follows:

"zendframework/zend-expressive-helpers": "dev-feature/template-variable-container as 5.2.0",

Then run composer update.

Copy link

@nuxwin nuxwin Feb 27, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@weierophinney

For the repository thing, I was aware of ;) The problem: I was not able to find your fork through your github profile ;) I must be blind...

However, I was not aware that you can alias a fork to a specific released version for dependencies purpose. That really great.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@weierophinney

Lol, you have a typo I think in your comment ;)

"zendframework/zend-expressive-json": "dev-feature/template-variable-container as 5.2.0",

JSON ;)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you have a typo I think in your comment

fixed.

@nuxwin
Copy link

nuxwin commented Feb 27, 2019

@weierophinney I'm not sure but I think that the current implementation is close to template. Let's talk about a request handler:

$content = $this->renderer->render(
     'some::template',
     $container->mergeForTemplate([
         'local' => 'value',
     ])
);

So basically put here, we are aggregating variables to a page template. Ok, but what if we want access those variables in the layout? The above variables will be close to the page template, not accessible from the layout. This was not the purpose of this feature after all? Or should we pre-render the layout inside the handler too? That sound a bit strange to me.

@nuxwin
Copy link

nuxwin commented Feb 27, 2019

@weierophinney

Ok... the feature (the variable container) will be close to the page template. Why? Layout is automatically rendered when the page template is rendered and in the Plates template, there is no check to see if the layout has been already rendered.

However, you can, from the page template, pass variables to the layout... OUCH.... For layout variables which are common to all pages, that seem a bit tedious but...


class RouteTemplateVariableMiddlewareTest extends TestCase
{
public function setUp()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

protected?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be either protected or public. We're not terribly consistent in the framework in terms of using one or the other. I typically define this and tearDown() as public.

@weierophinney
Copy link
Member Author

@nuxwin

However, you can, from the page template, pass variables to the layout... OUCH.... For layout variables which are common to all pages, that seem a bit tedious but...

For variables common to all pages, you would use a delegator factory on the TemplateRendererInterface service, and call its addDefaultParam() method.

The TemplateVariableContainer is for pipeline-specific parameters. In other words, for setting parameters you want to use for a subset of the site. You would push those from middleware, and your handler is then merging any variables it needs to pass to its template with those from the container.

Yes, it does mean that you will need to pass parameters for the layout from your template. You can simplify this in a number of ways, depending on the template engine you use:

  • Use a partial common to the subsection of the site, and have it assign any layout-specific parameters to the layout.

  • Utilize a common variable name to aggregate a set of layout variables, so that you can assign them en masse. As an example, using Plates: $this->layout('layout::admin', $layoutContainer).

  • Assign a separate template variable container for layout variables, and merge that in when rendering your template:

    $layoutContainer = $request->getAttribute('layout', new TemplateVariableContainer());
    $templateVars = $request->getAttribute(TemplateVariableContainer::class, new TemplateVariableContainer());
    $content = $this->renderer->render(
        'some::template',
        $templateVars->mergeForTemplate([
            'layout' => $layoutContainer,
            'some' => $templateSpecificVariable,
        ])
    );

    In this scenario, you would then assign the container via the layout() helper, using the mergeForTemplate() method:

    $this->layout('layout::admin', $layout->mergeForTemplate([])); // note: empty array argument!

My point is that there are a number of ways to make this work without requiring a ton of effort. The primary advantage is that it makes the system stateless, more explicit, and thus more predictable, all of which are necessary for async scenarios.

Is it as easy as using addDefaultParam()? No. Does it solve the state problem? Yes.

@vaclavvanik
Copy link

Isn't the solution to make TemplateRendererInterface immutable?

TemplateRendererInterface::withParam() : static

@weierophinney
Copy link
Member Author

@vaclavvanik

Isn't the solution to make TemplateRendererInterface immutable?

TemplateRendererInterface::withParam() : static

Yes, but not in that way!

If you return a new instance, you would then need to pass that instance via a request attribute to the middleware or handler that finally renders a template... which gets complicated quickly!

The approach we would need to take is two-fold:

  • Make addDefaultParam() return a new renderer instance. This would push users to only do that in a delegator factory, for parameters that need to be used everywhere, as that would be the only place you could return the instance and ensure every class depending on a renderer gets the same instance. This, however, is a BC break, so would require a new major version of zend-expressive-template.

  • Some functionality for aggregating parameters derived from request state (root path, route matched, authenticated user, etc.) that can be passed to the renderer at render() invocation. That's what this patch supplies.

The other thing to remember: using addDefaultParam() poses no problem when running under php-fpm normally, because the instance is specific to the given request, and is destroyed once the request is done. It's only when using a long-running, async server such as Swoole or ReactPHP that the state issues come into play. This patch is solely for that situation.

@vaclavvanik
Copy link

@weierophinney Well, there are imho two contexts for injecting default params. Container (factory, delegator) and middleware/handler.

My quick idea (which is bc break) - "split" TemplateRendererInterface to two:

  • TemplateRendererInterface - which would have only render method
  • VariableContainerInterface - which would have withParam(), withoutParam(), getParam()

This two should be used by template implementations and thus container context.
Your idea of TemplateVariableContainer should by used for middleware/handler context.

Than both approaches could be solved. Just my two cents :-)

@weierophinney
Copy link
Member Author

@vaclavvanik

Your idea of TemplateVariableContainer should by used for middleware/handler context.

Right - which is exactly the use case for the feature in this patch.

What's interesting about the approach is that it can be used for more than just templates; it could be used to aggregate request-specific values for any context. (Which makes me think that perhaps it should potentially get a different name, to allow for re-use between contexts.)

That said, there's still a reason for TemplateRendererInterface to have the addDefaultParam() method, as users will still want globally available default template values. To separate that use case from request-specific values, though, I think that will require changing that method to return a new instance, which will force users to either add all such parameters during initial instance creation (e.g., via delegator factories), or push the renderer instance itself as a request artifact (which is a bit problematic from a dependency standpoint). Either approach is a BC break, so we can use this approach (a variable container) in the meantime.

@vaclavvanik
Copy link

@weierophinney

What's interesting about the approach is that it can be used for more than just templates; it could be used to aggregate request-specific values for any context. (Which makes me think that perhaps it should potentially get a different name, to allow for re-use between contexts.)

Sure, I had the same idea as soon as I wrote my last post. But I think this new variable container should be stateless.

That said, there's still a reason for TemplateRendererInterface to have the addDefaultParam() method, as users will still want globally available default template values. To separate that use case from request-specific values, though, I think that will require changing that method to return a new instance, which will force users to either add all such parameters during initial instance creation (e.g., via delegator factories), or push the renderer instance itself as a request artifact (which is a bit problematic from a dependency standpoint). Either approach is a BC break, so we can use this approach (a variable container) in the meantime.

I agree. I hope zend-expressive-template-* will be soon updated so new variable container does not have to be used as a workaround.

Thumbs up.

@weierophinney
Copy link
Member Author

But I think this new variable container should be stateless.

Um, why? The use case it is specifically trying to solve is how to aggregate variables representing request state for use with rendering a template when in an async environment where aggregating state in a service has side-effects. (I'm wondering if we're approaching separate problems?)

@vaclavvanik
Copy link

It's my personal feeling - I do not like objects which could be changed somewhere under my hands. Second point of view - proposed variable container is just smart ArrayObject and arrays are immutable as well.

`TemplateRendererInterface::addDefaultParam()` has side-effects, and
should not be used in async environments unless setting template
variables that will be used on **every** request.

As such, this patch introduces an alternative workflow for aggregating
pipeline-specific template variables. It provides two classes:

- `Zend\Expressive\Helper\Template\TemplateVariableContainer`, which
  provides a way to aggregate template variables, as well as merge local
  variables for the purpose of producing a set to pass to the template
  renderer.
- `Zend\Expressive\Helper\Template\TemplateVariableContainerMiddleware`,
  which provides a way to register the container as a request attribute
  within your pipeline.

Middleware can then set template parameters using either the container's
`set()` or `merge()` methods:

```php
$request->getAttribute(TemplateVariableContainer::class)
    ->set('user', $user);

$request->getAttribute(TemplateVariableContainer::class)
    ->merge([
        'user'  => $user,
        'route' => $route,
    );
```

Handlers can then use the `mergeForTemplate()` method to prepare
variables to pass to the renderer:

```php
$content = $this->renderer->render(
    'some::template',
    $container->mergeForTemplate([
        'handler-specific-variable' => $value,
    ])
);
```
This patch builds on the previous patch, which introduced the
TemplateVariableContainer and associated middleware.

This middleware can replace the `UrlHelperMiddleware`, and be used to
set the discovered route from the `RouteResult` as the template variable
`route`.
Per a suggestion from @vaclavvanik, I have refactored the
TemplateVariableContainer to be immutable. `set()` was renamed to
`with()`, and `unset()` to `without()`. Each of these methods, as well
as `merge()`, now return a new instance cloned from the previous, with
the changes requested.

This also meant that the RouteTemplateVariableMiddleware needed updates
in order to reset the `TemplateVariableContainer` request attribute.
…emplateVariableMiddleware

This patch modifies the behavior of RouteTemplateVariableMiddleware in
the following ways:

- It now *always* populates a `TemplateVariableContainer` instance with
  a `route` parameter. This means if none was in the request previously,
  it will create one (via the default argument to `getAttribute()`).

- It populates the value with the `RouteResult`, which allow access to
  not just the matched route, but also any matched parameters. It also
  means users can test to see if a match actually occured.

These changes were made in light of zendframework/zend-expressive-platesrenderer#36,
which adds a `route()` template method, which will return the
`RouteResult` (not the route, nor the route name; the route result).
@weierophinney weierophinney force-pushed the feature/template-variable-container branch from 43505de to 0981643 Compare March 12, 2019 20:20
@weierophinney
Copy link
Member Author

@vaclavvanik I've made TemplateVariableContainer immutable at this time. I'm leaving it in this package, however, as it has no dependencies on zend-expressive-template, while if we were to add the RouteTemplateVariableMiddleware (which rightly belongs in this package, as it is analogous to some functionality in the UrlHelper + UrlHelperMiddleware), we would need to add a dependency here on zend-expressive-template.

Considering this is a standard package for Expressive applications, this is a reasonable tradeoff.

This patch updates the README and CHANGELOG to reflect current state of
the new classes.
@weierophinney weierophinney merged commit 8179d8a into zendframework:develop Mar 12, 2019
@weierophinney weierophinney deleted the feature/template-variable-container branch March 12, 2019 20:41
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants