Partial Content

Working with eZ Publish 5 - Subrequests

OK, now the real fun starts. 

So far, most of what we've done has been pretty analogous to eZ Publish 4, but we'll start to see some real differences here. 

At this point, our blog isn't much of a blog, because while it's displaying the blog object, it's isn't displaying the blog posts it contains. 

In eZ Publish 4, we'd just do something like this in the template:

{def $posts = fetch('content','tree', hash(
      'class_filter_type','include',
      'class_filter_array',array('blog_post')
))}
{foreach $posts as $post}
{node_view_gui content_node=$post view='summary'}
{/foreach}

That worked fine, but it also tightly binds our template architecture to the the repository. And if you want to run any additional code, the only option you really have is to write the code right in the template. eZ Template language isn't well-suited to this role, so it can lead to some real spaghetti, and ultimately maintenance problems. 

The solution is to use the right tool for the right job: PHP for retrieving and preparing the data, and templates for formatting the output. So how do we do this?

Recall that symfony uses a Model-View-Controller architecture. A request comes in, gets routed to an action on a Controller, where data is assembled from the Model and then handed off to a View for display. We've been using this structure all along, it's just that we've been using a Controller action provided by eZ Publish. If you want, you can take a look at it yourself on github (the viewLocation function). 

Symfony provides a sub-request system, which is basically a way of sending a request to a second MVC action from inside your first, and that's what we'll use here. To do that, we'll need to:

  • create a new controller
  • tell symfony about it
  • build the templates we need

To get started on this step, you can bring your tutorial code up to date with the following commands. As before, this will wipe out any local changes you've made in the tutorial, so take care.

git checkout -- *
git clean -f -d
git checkout -b tutorial-4 origin/tutorial-4

The "Hello World" Controller

The first thing we'll do is create the controller. This will be the most complicated piece, but it's all downhill from here.

Add a new PHP file to src/Blend/TutorialBlogBundle/Controllers called 'BlogController.php', and add the following code:

<?php
namespace Blend\TutorialBlogBundle\Controller;
//The above defines our PHP namespace

//This declares the components we'll be using 
use Symfony\Component\HttpFoundation\Response,
 eZ\Publish\Core\MVC\Symfony\Controller\Content\ViewController as APIViewController;
 
/**
 * BlogController provides basic sub-request methods used by the tutorial blog
 * Extends APIViewController which provides some convenience methods for handling request/response objects
 */
class BlogController extends APIViewController
{
    public function postsByDate()
    {
     //Response is a symfony class that defines all the data of an HTTP response
        return new Response("<h1>HI FROM BlogController</h1>");
    }
}

We've defined a controller with one method, postsByDate. We'll use this to get a templated set of blog post summaries sorted by date. For now, though, we're just going to get back our text. 

Wiring it up

Now that we have our controller action, we need to let Symfony know about it, and call it from our template.

Open up src/Blend/TutorialBlogBundle/Resources/config/services.yml. This is where we define the services that your bundle provides. Symfony uses a Dependency Injection Container, which is a fancy way of saying that rather than manually instantiating your classes in code, you can define the pieces they need and how they should be instantiated in the configuration. The end result is that code components only depend on interfaces rather than directly depending on other classes, making the entire framework (your code included) more maintainable and easier to unit test.

Right now your services.yml probably just has some commented-out sample lines. Delete that stuff and put this in instead:

#These parameters are defined like variables. They can prevent you from needing to 
#change a bunch of code just because you changed your class name.
parameters:
    blend_tutorial_blog.blogcontroller.class: Blend\TutorialBlogBundle\Controller\BlogController

services:
    #This tells symfony what is needed to instantiate our controller
    blend_tutorial_blog.controller:
        class: %blend_tutorial_blog.blogcontroller.class%
        arguments: [@ezpublish.view_manager]
        calls:
            - [setContainer, [@service_container] ]

    #This adds a short name 'tblog' for our controller, which is nicer to type in templates
    tblog:
        alias: blend_tutorial_blog.controller

Now add this line to your src/Blend/TutorialBlogBundle/Resources/view/full/blog.html.twig template, right below the ez_render_field line:

{{ render( controller( "tblog:postsByDate" ) ) }} 

The twig 'render' function retrieves the result of another MVC request and includes it in the template. If you refresh your page (you will need to shift/cmd-reload to bypass the browser cache), you should see your controller in action: 

Hello World Controller Screenshot

Bingo!

Do Something Useful

Of course, if we just wanted text we could have typed it in the template. Let's retrieve those blog posts. 

To do that, we'll use the eZ Publish Public API. One of the engineering problems that eZ Publish 5 attempts to address is figuring out which classes and methods in the core system were safe for authors to call, and which parts were going to change in future releases. The public API defines the interaction layer, and allows for more flexibility behind the scenes for things like pluggable storage engines and other future enhancements, without breaking existing code.

From an architectural standpoint, this is great. Unfortunately, the Public API can often be pretty verbose in the case of simple actions, and could use a bit more syntactic sugar. But we'll focus on how it works here, and I'll save the editorializing for a future post.

(If you don't feel like typing, you can reach into the future and copy and paste all this from the next tutorial step on github)

The first thing you'll need to do is add a few more classes to your controllers 'use' statement: 

use Symfony\Component\HttpFoundation\Response,
    eZ\Publish\Core\MVC\Symfony\Controller\Content\ViewController as APIViewController,
    eZ\Publish\API\Repository\Values\Content,
    eZ\Publish\API\Repository\Values\Content\Query,
    eZ\Publish\API\Repository\Values\Content\Query\Criterion,
    eZ\Publish\API\Repository\Values\Content\Search\SearchResult,
    eZ\Publish\API\Repository\Values\Content\Query\SortClause;

I've broken the controller code up in to two methods: The controller action itself (replacing our 'hello' method) ...

    /**
     * postsByDate returns a formatted list of all posts beneath a location id(aka node id)
     * Posts are retrieved from the repository and returned in reverse chronological order
     * @param $subTreeLocationId The location ID (node ID) to look under
     * @param string $viewType What type of view template should render each result
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function postsByDate($subTreeLocationId, $viewType='summary')
    {
        //Retrieve the location service from the Symfony container
        $locationService = $this->getRepository()->getLocationService();
 
        //Load the called location (node) from the repository based on the ID
        $root = $locationService->loadLocation( $subTreeLocationId );
 
        //Get the modification time from the content object
        $modificationDate = $root->contentInfo->modificationDate;
 
        //Retrieve a subtree fetch of the latest posts
        $postResults = $this->fetchSubTree(
            $root,
            array('blog_post'),
            array(new SortClause\Field('blog_post','publication_date',Query::SORT_DESC))
        );

        //Convert the results from a search result object into a simple array
        $posts = array();
        foreach ( $postResults->searchHits as $hit )
        {
            $posts[] = $hit->valueObject;
 
            //If any of the posts is newer than the root, use that post's modification date
            if ($hit->valueObject->contentInfo->modificationDate > $modificationDate) {
                $modificationDate = $hit->valueObject->contentInfo->modificationDate;
            }
        }
 
        //Set the etag and modification date on the response
        $response = $this->buildResponse(
            __METHOD__ . $subTreeLocationId,
            $modificationDate
        );
 
        //If nothing has been modified, return a 304
        if ( $response->isNotModified( $this->getRequest() ) )
        {
            return $response;
        }
 
        //Render the output
        return $this->render(
            'BlendTutorialBlogBundle::posts_list.html.twig',
            array( 'posts' => $posts, 'viewType' => $viewType ),
            $response
        );
    }

... and a convenience method to fetch a tree of content, keeping our controller code more legible.

    /**
     * A convenience method to provide a simple method for retrieving selected objects.
     * Returns all content object from a subtree of content by type, based on the location
     * @param Location $subTreeLocation The location object representing a location (node) in the repository
     * @param array $typeIdentifiers an array of string containing identifiers for ContentTypes
     * @param array $sortMethods An array of sort methods
     * @return \eZ\Publish\API\Repository\Values\Content\Search\SearchResult
     * @todo Factor this method out as a service to be used by other controllers
     */
    protected function fetchSubTree(
        \eZ\Publish\API\Repository\Values\Content\Location $subTreeLocation,
        array $typeIdentifiers=array(),
        array $sortMethods=array()
    )
    {
 
        //Access the search service provided by the eZ Repository (Public API)
        $searchService = $this->getRepository()->getSearchService();
 
        $criterion = array(
            new Criterion\ContentTypeIdentifier( $typeIdentifiers ),
   new Criterion\Subtree( $subTreeLocation->pathString )
        );
 
        //Construct a query
        $query = new Query();
        $query->criterion = new Criterion\LogicalAnd(
            $criterion
        );

        if ( !empty( $sortMethods ) )
        {
            $query->sortClauses = $sortMethods;
        }
        $query->limit = 20;
 
        //Return the content from the repository
        return $searchService->findContent( $query );
    }

That's a lot more code than we're used to for a fetch, but most of it is involved with cache control. Since eZ Publish 5 doesn't have the multiple layers of caching that 4 did, caching is easier to understand, but it also needs to be considered when generating any data. When we set up the response object for the controller, we're giving it a string unique to our output, and a time when the output was last modified: 

//Set the etag and modification date on the response
$response = $this->buildResponse(
    __METHOD__ . $subTreeLocationId,
    $modificationDate
);

These values are used to populate the HTTP ETag and Last-Modified headers, meaning they will inform cache behavior for Symfony, frontend caches like varnish or squid, intermediate proxies, and the user's browser all at once. As a developer, this gives you a great deal more control in how your output is managed in caching, and the use of the HTTP spec means that all of the layers will work in concert.

A couple of other interesting notes from this code: 

  • We added a couple of parameters to our postsByDate function. These can be called from the template via the Twig render function.
  • The line '$locationService = $this->getRepository()->getLocationService();' give you a pretty good example of the other side of dependency injection, consuming services. Here we're consuming a location service defined by the eZ Publish Core, similar to how we defined our controller in services.yml. $this->getRepository is a method provided by our parent class that just makes it easy to retrieve repository services from Symfony's service container, which is how you get all of these magically-instantiated objects. Later on we'll examine how to use the service container directly when you're running code outside of a controller.
  • If you look at the fetchSubtree method, you'll note that sort clauses and filters are defined as separate classes. This means they're easily extensible. 
  • Also in fetchSubtree, it's worth noting that this code would be identical for querying content from the database, a Solr index, or any other future storage engine that the repository might use.

More Templates

One more item of note in the controller: we've replaced our return response with a call to a template rather than a simple response. It's a template that doesn't currently exist. Let's fix that.

Add a new template, src/Blend/TutorialBlogBundle/Resources/views/posts_list.html.twig:

{# Iterate over the list of posts and render each individually #}
{% for post in posts %}
    {{
        render(
            controller(
                "ez_content:viewLocation",
                {
                    'locationId': post.contentInfo.mainLocationId,
                    'viewType': viewType
                }
            )
        )
    }}
{% endfor %}

This template uses the two variables that we provided when we called the template from our controller, 'posts' and 'viewType'. These are being passed to another render call, which calls eZ's viewLocation controller for each retrieved content. This seems like a lot of sub-request calling, but with the cache headers set properly, most of these will be very fast. 'viewType' is actually set by an input variable to our postsByDate method on the controller, so it's a variable we're passing through to the template to allow us to decide what type of presentation to give our objects without writing more code. The same controller action can be used for many different presentations, as long as the data is the same.

So far the only template we have for blog posts is a 'full' template - a full-page render. We'll want a summary view to list the posts on the blog page. Let's create that now by adding a new template: src/Blend/TutorialBlogBundle/Resources/views/summary/blog_post.html.twig:

{% extends viewbaseLayout %}
{% block content %}
    <article class="summary blog_post">
        <h2><a href="{{ path( location ) }}">{{ location.contentInfo.name }}</a></h2>
        {# If the summary is empty, render the body #}
        {# This is likely not best practice since it's language-specific #}
        {% if content.fields.summary['eng-US'].xml|xmltext_to_html5 %}
            {{ ez_render_field( content, "summary" ) }}
        {% else %}
            {{ ez_render_field( content, "body" ) }}
        {% endif %}
    </article>
{% endblock %}

We'll also need to register this new template by adding to ezpublish/config/override.yml so that eZ Publish knows when to use it: 

                summary:
                    blog_post:
                        template: BlendTutorialBlogBundle:summary:blog_post.html.twig
                        match:
                            Identifier\ContentType: blog_post

(Why didn't we register posts_list.html.twig? Because our controller called that directly. Since the eZ Publish controller didn't use it, it didn't need to be registered.

Finally, we'll modify our render statement in src/Blend/TutorialBlogBundle/Resources/views/full/blog.html.twig to take advantage of the parameters we added to the controller action:

     {{
      render (
       controller(
        "tblog:postsByDate",
        {
         'viewType': 'summary',
         'subTreeLocationId': location.id
        }
       )
   )
     }}

At long last, a shift-reload should bring us our blog posts!

Posts List screenshot

This is starting to look a lot like a web site! Next we'll see how we can insert some additional objects to help us format the page.