Upgrade Your Drupal Skills

We trained 1,000+ Drupal Developers over the last decade.

See Advanced Courses NAH, I know Enough

Single Page Interface with Drupal

Parent Feed: 

image

We recently built a community app in Drupal. It has:

  1. a fully abstracted (no Drupal), single page (no reloads) frontend
  2. a web service enabled Drupal backend
  3. an integrated Drupal overlay for edit and admin pages

Here’s how we did it:

Setting up Drupal web services

The Drupal Services module provides a standard framework for defining web services. This was our foundation for making Drupal data available to an external application. Out of the box it supports multiple interfaces like REST, XMLRPC, JSON, JSON-RPC, SOAP, AMF and provides functional CRUD services for core Drupal entities like nodes, users, and taxonomy terms. The response data is structured much like the Drupal entity objects you’re used to seeing in PHP and provides all the same data. We added our own group of “UI” services to clean up these objects and strip out some of the data that isn’t relevant to the UI layer (lots of [‘und’] arrays). I’m hoping to make this into a contrib module sometime soon.

A response to the standard Services resource /rest/user/<uid>.json looks something like this:

{
  uid: "222",
  name: "tway",
  field_first_name: {
    und: [
      {
        value: "Todd",
        format: null,
        safe_value: "Todd"
      }
    ]
  },
  field_last_name: {
    und: [
      {
        value: "Way",
        format: null,
        safe_value: "Way"
      }
    ]
  },
  field_location: {
    und: [
      {
        tid: "604"
      }
    ]
  },
  field_department: {
    und: [
      {
        tid: "614"
      }
    ]
  },
  ...
}

And a response to our UI resource /rest/user/<uid>/ui.json looks like this:

{
  uid: "222",
  name: "tway",
  field_first_name: {
    label: "Todd"
  },
  field_last_name: {
    label: "Way"
  },
  field_location: {
    id: "604",
    label: "KC-Airport",
    type: "taxonomy_term"
  },
  field_department: {
    id: "614",
    label: "Technology",
    type: "taxonomy_term"
  },
  edit_link: "user/222/edit",
  display_name: "Todd Way",
  ...
}

Making authenticated web service requests from your frontend app

The Services module comes with support for session-based authentication. This was essential for our app because we did not want any of our content or user data to be publicly available. Each request had to be associated with an authorized user. So basically, if a valid session key is set (as a request header cookie) on a service request, Drupal will load the user associated with that session key - just like any standard Drupal page request. There are two ways to accomplish this with an external frontend.

Option 1: Run your app on the same domain as Drupal

If your app can run on the same web domain as the Drupal services backend, you can use the built-in Drupal login form to handle authentication for you. It will automatically set the session key cookie and pass it on any service requests from the browser on that domain. So for example if your Drupal site is at http://mysite.com and your Drupal login is at http://mysite.com/user, your UI app will be at something like http://mysite.com/my-ui-path (more on how to set this up later).

To make a jQuery-based service request for a user object you would simply need to do this:

(function ($) {
    $.getJSON('/rest/user/1.json', function(data) {
      console.log(data);
    });    
})(jQuery);  

The response data, if the request was properly authenticated, would be:

{
 "uid":"1",
 "name":"admin",
 "mail":"[email protected]",
 "created":"1354058561",
 "access":"1363899033",
 "login":"1363725854",
 "status":"1",
 "timezone":"America/Chicago",
 "roles":[
    2
 ],
} 

and if unauthenticated would be:

[
  "Access denied for user anonymous"
]

Option 2: Run your app on a separate domain

If your app will be on a separate domain it will need it’s own server (e.g. node.js, etc.) to proxy all authenticated service requests. One reason for this is that web browsers do not allow the Cookie header to be set on XMLHttpRequest from the browser (see the W3C Spec). You can get around this on GET requests with JSONP if you do something like this:

$.ajax({
     type: 'GET',
     url: 'http://mysite.com/rest/uiglobals.jsonp?callback=jsonpCallback',
     async: false,
     jsonpCallback: 'jsonpCallback',
     contentType: "application/json",
     dataType: 'jsonp',
     success: function(json) { console.log(json); },
     error: function(e) { console.log(e.message); }
 });

However JSONP does not allow POST requests, so this is not a complete solution. For more details, check out this article.

Your proxy server will need to call an initial login service request (already part of the services module) on behalf of the client browser that takes a username and password and, if valid, returns a session key. The server then needs to pass the session key in the Cookie header on all service requests. If you were using a second Drupal site for your proxy, the PHP would look something like this:

<?php
function example_service_request() {
  $server = 'http://example.com/rest/';

  //login request - we need to make an initial authentication request before requesting protected data
  $username = 'username';
  $password = 'password';
  $url = $server . 'user/login.json';
  $options = array(
    'method' => 'POST',
    'headers' => array('Content-Type' => 'application/json'),
    'data' => json_encode(array(
      'username' => $username,
      'password' => $password,
    )),
  );  

  $result = drupal_http_request($url, $options['headers'], $options['method'], $options['data']);  //d6 
  //$result = drupal_http_request($url, $options); //d7

  if ($result->code != 200) {
    drupal_set_message(t('Authentication error: ') . $result->status_message, 'error');
  }
  $login_data = json_decode($result->data);

  //build the session cookie from our login repsonse so we can pass it on subsequent requests
  $cookie = $login_data->session_name . "=" . $login_data->sessid . ";"; 

  //user search request
  //$url = $server . 'search/user_index/ui.json';
  $url = $server . 'search/user_index/ui.json?keys=joe&sort=field_anniversary:DESC'; 

  $options = array(
    'method' => 'GET',
    'headers' => array(
      'Content-Type' => 'application/json',
      'Cookie' => $cookie, //add our auth cookie to the header
    ),
  );
  $result = drupal_http_request($url, $options['headers'], $options['method'], $options['data']);  //d6 
  //$result = drupal_http_request($url, $options); //d7
  dpm(json_decode($result->data), 'result data');

  //Log out request, since we are done now.
  $url = $server . 'user/logout.json';
  $options = array(
    'method' => 'POST',
    'headers' => array('Cookie' => $cookie),
  );
  $result = drupal_http_request($url, $options['headers'], $options['method'], $options['data']);  //d6     
  //$result = drupal_http_request($url, $options); //d7
}

We didn’t use this code for our UI app, but it came in handy for testing and we eventually used it to interact with data from another backend system.

For our UI app, we used option 1 for two main reasons:
1. No need for a separate frontend server or custom authentication handling.
2. Better integration with the Drupal overlay (more on this later).

Hosting the frontend app for local development

We didn’t want our frontend developers to need any Drupal knowledge or even a local Drupal install in order to develop the app. We set up a web proxy on our shared Drupal development environment so frontend developers could build locally against it while appearing to be on the same domain (to maintain the cookie-based authentication). We used a simplified version of PHP Simple Proxy for this and added it to the Drupal webroot, but Apache can be configured to handle this as well. I wouldn’t recommend using a Drupal-based proxy since each request would perform unnecessary database calls during the Drupal bootstrap.

Our frontend developers used node.js and localtunnel, but other local dev tools could be used for this. As long as the Drupal development server can make requests to the frontend developer’s machine, the web proxy will work. Using this setup, the URL for frontend development looks something like this…

http://mysite.devserver.com/proxy.php?url=myfrontend.localtunnel.com

…where mysite.devserver.com is the domain alias of the dev server, proxy.php is the name of the PHP proxy script, and myfrontend.localtunnel.com is the domain alias for a frontend developer’s machine.

Hosting the frontend app in Drupal

To make the frontend app easy to deploy along with the Drupal backend, we set up a simple custom Drupal module to host it. Since the app is just a single HTML page (and some JS and CSS files), we define one custom menu item and point it to a custom TPL.

Here are the essential pieces for our judeui.module file:

<?php
/**
 * implements hook_menu
 */
function judeui_menu() {
  //define our empty ui menu item
  $items['ui'] = array(
    'page callback' => 'trim', //shortcut for empty menu callback
    'page arguments' => array(''),
    'access callback' => TRUE,
  );
  return $items;
}

/**
 * implements hook_theme
 */
function judeui_theme() {
  //point to our custom UI TPL for the 'ui' menu item
  return array(
    'html__ui' => array(
      'render element' => 'page',
      'template' => 'html__ui',
    ),
  );
}

/**
 * implements hook_preprocess_html
 * @param type $vars
 */
function judeui_preprocess_html(&$vars) {
  //if we're serving the ui page, add some extra ui variables for the tpl to use
  $item = menu_get_item();
  if ($item['path'] == 'ui') {        
    $vars['judeui_path'] = url(drupal_get_path('module', 'judeui'));
    $vars['site_name'] = variable_get('site_name', '');

    //add js to a custom scope (judeui_scripts) so we can inject global settings into the UI TPL
    drupal_add_js(
      "var uiglobals = " . drupal_json_encode(_get_uiglobals()), 
      array('type' => 'inline', 'scope' => 'judeui_scripts')
    );
    $vars['judeui_scripts'] = drupal_get_js('judeui_scripts');
  }
}

The hook_menu function defines our ui page, the hook_theme function points it at our custom TPL, and the hook_preprocess_html lets us add a few custom variables to the TPL. We use the judeui_scripts variable to get global settings from Drupal into the page - much like the Drupal.settings variable on a standard Drupal page. We also have a web service that the ui app could use for this, but adding this directly to the page saves an extra request when intially building the page. More on ui globals in the next section.

And here is our custom html_ui.tpl.php file:

<!DOCTYPE html>
<html>
    <head>
        <title><?php echo $site_name ?></title>
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
        <?php echo $judeui_scripts ?>
        <script src="http://toddway.com/post/46638775215/<?php echo $judeui_path ?>/app.js"></script>
        <link href="http://toddway.com/post/46638775215/<?php echo $judeui_path ?>/app.css" rel="stylesheet"/>      
    </head>
    <body>
    </body>
</html>

It contains the few very basic PHP variables that we set in hook_preprocess_html and a small amount of HTML to set the page up. Frontend developers can build and deploy app updates simply by committing new app.js and app.css files to the module folder. Drupal serves the page at http://mysite.com/ui.

Global settings for the UI

We added a custom web service to pass global settings to the UI app. The frontend app can call http://mysite.com/rest/uiglobals.json to load this or use the uiglobals variable we added to the UI TPL in the section above. Both of these methods use a function that returns an array of settings that are useful to the UI app.

<?php
function _get_uiglobals() { 
  return array(    
    'basePath' => base_path(), 
    'site-name' => variable_get('site_name', ''),
    'site-slogan' => variable_get('site_slogan',''),
    'publicFilePath' => file_stream_wrapper_get_instance_by_uri('public://')->getDirectoryPath(),
    'privateFilePath' => 'system',
    'main-menu' => _get_uimenu('main-menu'),
    'user-menu' => _get_uimenu('user-menu'),  
    'image-styles' => array_keys(image_styles()),
    'user' => $GLOBALS['user']->uid,
    'messages' => drupal_get_messages(NULL, TRUE),
  );
}

You can see it contains global data like base path, site name, currently logged in user, public file path, image styles, messages, etc. This is a handy way for the frontend to access data that shoudn’t change during the browser session.

Integrating standard Drupal pages/URLs

In the early stages of frontend development it was quite useful to model the pages in a standard Drupal theme. For a while we thought we might still want some parts of the site to just be standard Drupal pages. Handling this incremently was fairly simple.

We established some conventions for URL aliases patterns in both Drupal and the frontend app. For example, one of our content types is post. The URL alias pattern for posts is post/[node:nid]. So we had a Drupal-themed URL for the post at http://mysite.com/post/123 and a frontend URL at http://mysite.com/ui#post/123.

Once the frontend app was ready to start handling posts, we used hook_url_inbound_alter to redirect http://mysite.com/post/123 to http://mysite.com/ui#post/123.

<?php
/**
 * implements hook_url_inbound_alter
 */
function judeui_url_inbound_alter(&$path, $original_path, $path_language) {
  //dpm($path, $original_path);
  if (variable_get('site_frontpage', 'node') == 'ui') {
    $oargs = explode('/', $original_path);
    if (in_array($oargs[0], array('post', 'user', 'group', 'tool')) 
        && !isset($oargs[2]) && isset($oargs[1]) && is_numeric($oargs[1])) {
      drupal_goto('ui/' . $original_path);
    }
    if (strpos($original_path, 'user/edit') === 0) {
      $frag = 'modal/' . str_replace('user/edit', 'user/' . $GLOBALS['user']->uid . '/edit', $original_path);
      drupal_goto('ui/' . $frag);
    }
  }
}

This is incredibly handy for redirecting preconfigured links in Drupal views or email notifications to our abstracted UI URLs. And hook_url_inbound_alter can be expanded as more of the app moves to the abstracted UI.

Integrating the Drupal overlay

We wanted to use the standard content editing and admin pages that Drupal provides and have those pages open in an overlay just like any other Drupal 7+ site. To make it appear like part of the abstracted frontend, links to Drupal-rendered pages open in an iframe with 100% height and 100% width (just like the Drupal 7 admin overlay), and we made some minor CSS tweaks to the Drupal theme so that the page appears to be a modal window in front of the abstracted UI. Now edit and create links throughout our abstracted frontend can open the iframe overlay and display pure Drupal admin pages.

image

In addition we needed to facilitate closing the modal when appropriate. Setting up a close link in the top right corner of the modal was a pretty straightforward javascript exercise, but we also wanted to close it automatically when a user completed a task in the modal (For example, when a user clicks save after editing a content item, we want the modal to close on it’s own). Drupal already has a way to handle a redirect after a task (usually a form submit) is complete - the destination query string parameter. So in our frontend app, we add a destination query parameter to all of the edit and create links. The frontend app listens to the onload event of the iframe, and if it redirects to a non-modal page (e.g. /ui ), it closes the modal.

Finally, we want to pass Drupal messages back to the abstracted UI so the user can still see them even if the modal closes. Since the modal is redirecting to the ui callback when it closes, the messages variable of the uiglobals array will contain any related messages that should be displayed to the user.

Final thoughts

This was our first attempt using this kind of site architecture with Drupal. Although there were new development challenges inherent to any single page web application (and best saved for another post), the integration with Drupal as a backend and as an adminstrative frontend was suprisingly smooth. Our community site incorporates other contrib modules like Organic Groups, Search API Solr, Message Notify, and CAS without issue. Here are some additional benefits we discovered:

  1. Full suite of reusable UI web services for other client apps (Android, iOS, etc).
  2. Free to use a frontend development team with no Drupal knowledge.
  3. Avoided many of the usual Drupal theming struggles and limitations.
  4. Relatively seamless integration of Drupal UI and abstracted UI.
  5. Progressive integration (You don’t have to build the entire UI outside of Drupal - convert it later, if desired)
Original Post: 

About Drupal Sun

Drupal Sun is an Evolving Web project. It allows you to:

  • Do full-text search on all the articles in Drupal Planet (thanks to Apache Solr)
  • Facet based on tags, author, or feed
  • Flip through articles quickly (with j/k or arrow keys) to find what you're interested in
  • View the entire article text inline, or in the context of the site where it was created

See the blog post at Evolving Web

Evolving Web