diff --git a/blog/_posts/2013-05-07-embeddable-and-context-aware-web-pages.md b/blog/_posts/2013-05-07-embeddable-and-context-aware-web-pages.md new file mode 100644 index 0000000..e688a83 --- /dev/null +++ b/blog/_posts/2013-05-07-embeddable-and-context-aware-web-pages.md @@ -0,0 +1,154 @@ +--- +title: Embeddable and Context-Aware Web Pages +layout: post +tags: architecture http javascript symfony +description: Embedding content in an absolutely relative manner. +--- + +In my [symfony][5] 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][1]. The [facets][2] and [results][3] 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][3] 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][4] 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: + +{% highlight php %} +$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']); +{% endhighlight %} + +So now whenever I want a subcontext within a view, I can use the custom renderer: + +{% highlight jinja %}{% raw %} +{{ render_subcontext(path('search_results', passthru), { 'name' : 'r' }) }} +{% endraw %}{% endhighlight %} + +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: + +{% highlight jinja %}{% raw %} +
+

{{ block('def_title') }}

+
{{ block('frag_' ~ subcontext_frag()) }}
+
+{% endraw %}{% endhighlight %} + +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 +
+ + // clicked anchor + + + // 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][6]. + + +### 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 abuot page-specific hacks for passing data to subrequests. + + + [1]: http://www.theloopyewe.com/shop/search/availability/in-stock/?q=red + [2]: http://www.theloopyewe.com/shop/search/availability/in-stock/facets.html?q=red + [3]: http://www.theloopyewe.com/shop/search/availability/in-stock/results.html?q=red + [4]: http://twig.sensiolabs.org/ + [5]: http://symfony.com/ + [6]: http://diveintohtml5.info/history.html