Embeddable and Context-Aware Web Pages

May 7, 2013

In my symfony website applications I frequently make multiple subrequests to reuse content from other controllers. For simple, non-dynamic content this is trivial, but when arguments can change data or when the browser may want to update those subrequests things start to get complicated. Usually it requires tying the logic of the subrequest controller in the main request controller (e.g. knowing that the q argument needs to be passed to the template, and then making sure the template passes it in the subrequest). I wanted to simplify it and get rid of those inner dependencies.

As an example, take a look at this product search. The facets and results are actually subrequests, but the main results content is taking advantage of the request design I implemented. My goals were:

  • remove logic from controller code to keep them independent from each other,
  • pages work without JavaScript and without requiring newer browsers,
  • pages work the same whether it's a subrequest or a master request, and
  • any page should be capable of being a self-contained subrequest.

Steps

When a subrequest is self-contained, I call it a subcontext. These subcontext requests have an additional requirement of being publicly accessible. In the product search, the results page is publicly routed and all the pagination and view links will work properly within the ./results.html page. This makes it easy for using XHR to load updated content.

Another minor piece of this design is that views don't need to be fully rendered. This means an Ajax request can ask for just the page content and exclude the typical header/footer. In Twig parlance it is a frag_content block which has all the useful content.

When it comes to passing query parameters down through subcontexts, I decided that each subcontext gets its own scoped variable. So whenever I render a subcontext in a template, I always specify a name for it. The name should be unique within the template context. In the product search example, the facets subcontext is named f and the results subcontext is named r. When a request arrives for /?r[offset]=54, the subrequest will arrive at the results controller looking like /results.html?offset=54 (which is equivalent to navigating that page directly).

To keep track of the subcontext names, template content, query data, and relative locations I started using a custom request header named tle-subcontext. In practice it looks like:

tle-subcontext: r:content@/shop/search/availability/in-stock/?q=red

When that request header exists it means:

  • we're within a subcontext named r,
  • we want to get the view fragment named content, and
  • the root URL we started at was /shop/search/availability/in-stock/?q=red.

Within the controller code that header information should not be relevant. In templating though it becomes useful for rewriting URLs. Whenever a template is going to give a link to itself, I wrap it in a custom subcontext_rewrite function. For example, given the tle-subcontext configuration above, it would rewrite:

dataset_generic(...snip...)
=> /shop/.../in-stock/results.html?q=red&view=list-tn&offset=54

subcontext_rewrite(dataset_generic(...snip...))
=> /shop/.../in-stock/?q=red&r[view]=list-tn&r[offset]=54#r

The rewritten URL is completely valid and can be accessed without fancy JavaScript calls. Now, to make that possible I don't use the standard inline renderer in Twig. I created a custom renderer with a little additional logic which takes care of rewriting the subcontext data and injecting the header:

$rootUri = $request->getRequestUri();

if (preg_match('/^([a-z0-9\-]+):([a-z0-9]+)@(.*)$/', $request->server->get('HTTP_TLE_SUBCONTEXT'), $match)) {
    # this means a subcontext already exists and a sub-subcontext is being created

    # append our context name to the parent context name
    $options['name'] = $match[1] . '-' . $options['name'];

    # use the root uri from the header since $request is only a subrequest
    $rootUri = $match[3];

    # pull out our context-specific query data from the root uri and update our request
    parse_str(parse_url($match[3], PHP_URL_QUERY), $rootQuery);
    $subRequest->query->replace(isset($rootQuery[$options['name']]) ? $rootQuery[$options['name']] : array());
} elseif ((null !== $subdata = $request->query->get($options['name'])) && (is_array($subdata))) {
    # pull out our context-specific query data
    $subRequest->query->replace($subdata);
}

# now add the header with all our combined data to the request
$subRequest->server->set(
    'HTTP_TLE_SUBCONTEXT',
    $options['name'] . ':' . (empty($options['frag']) ? 'content' : $options['frag']) . '@' . $rootUri
);

unset($options['name'], $options['frag']);

So now whenever I want a subcontext within a view, I can use the custom renderer:

{{ render_subcontext(path('search_results', passthru), { 'name' : 'r' }) }}

With those simple customizations I no longer have to worry about knowing what parameters need to be passed on to template subrequests. It also paves the way for some more fancy behavior...

Adding Some Magic

Since the subcontext pages are publicly accessible, it should be easy to let Ajax reload individual subcontexts without having to reload the whole page. To enable that, I went ahead and configured subcontext requests to always end up in a specific layout which will wrap it with the subcontext metadata. The template looks like:

<article id="{{ subcontext_name() }}" data-href="{{ app.request.uri }}#{{ subcontext_frag() }}">
    <header><h3>{{ block('def_title') }}</h3></header>
    <section>{{ block('frag_' ~ subcontext_frag()) }}</section>
</article>

The subcontext_* custom functions simply peek at the request to find the tle-subcontext header and appropriate values.

Now that the extra data is available, we can have JavaScript build links to send partial requests. If a subcontext_rewrite link is clicked, it's a matter of starting with the article[@data-href] value and peeking at the clicked a[@href] to find query parameters that were within the article[@id] name. For example:

// window.location
/shop/search/availability/in-stock/?q=red

// subcontext
<article id="r" data-href="/shop/search/availability/in-stock/results.html?q=red#content">

// clicked anchor
<a href="/shop/search/availability/in-stock/?q=red&r[offset]=54#r">

// becomes the request
GET /shop/search/availability/in-stock/results.html?q=red&offset=54
tle-subcontext: tle-subcontext: r:content@/shop/search/availability/in-stock/?q=red

Something easily processable with an Ajax request. And since the clicked anchor was a canonical URL, that easily becomes the new window URL location by using the HTML5 History API.

Conclusion

Once I implemented the code snippets for tying all the ideas together, it became much quicker and simpler for me to embed other dynamic controllers within my requests. So far it has been working out quite well and I no longer have to worry about page-specific hacks for passing data to subrequests.