Browserless Drupal:
Render Unto Caesar, Don't Render Unto APIs
https://slides.com/lawrencemiller/renderuntocaesar
"Headless?"
So, What Are We Talking About Here?
I mean, isn't this just Web Services?
There's no "just" in "Web Services"
Ask anyone who wrote code before about 1998. Seriously.
Drupal as an Endpoint
Drupal as a Component
Chapter 1:
The Drupal Data Model
Doing things the
Drupal way:
- Nodes
- Views
Diverging from the Drupal Way
Nodes are spread out across LOTS of tables; writes are not cheap operations. Sometimes a dedicated table is appropriate.
Of course we can add a pie chart to check the status.
(Using charts and charts_google)
YACM
/**
* @param $node
* @param $view_mode
*/
function uploader_batch_view_node_view($node, $view_mode)
{
if ($node->type == "batch" && $view_mode == 'full') {
// count the number of non-new status documents from this batch
$non_new = db_query("select ds.status_text, count(*) from {documents} d INNER JOIN document_statuses AS ds on (d.status = ds.status_id) WHERE d.batch_id = $node->nid group by ds.status_text")->fetchAllKeyed();
$node->content['batch_data'] = $non_new;
return $node;
}
}
/**
* @param $batchID
* @param string $limit
* @return string
*/
function uploader_batch_view_render_documents($batchID, $limit = '10')
{
$header_row = array(
array('data' => 'Document ID', 'field' => 'd.document_id'),
array('data' => 'Text', 'field' => 'd.document_text'),
array('data' => 'Status', 'field' => 'ds.status_text'),
);
$query = db_select('documents', 'd')
->condition('batch_id', $batchID)
->fields('d', array('document_id','document_text'))
->fields('ds', array('status_text'));
$query->join('document_statuses', 'ds', 'd.status = ds.status_id');
$query = $query->extend('PagerDefault')->limit($limit);
$query = $query->extend('TableSort')->orderByHeader($header_row);
$result = $query->execute();
foreach ($result as $row) {
$table_rows[] = array($row->document_id, $row->document_text, $row->status_text);
}
return theme_table(array('header' => $header_row,
'rows' => $table_rows, 'attributes' => array(), 'empty' => 'Data not found',
));
}
node--whatever.tpl.php
<article id="node-<?php print $node->nid; ?>" class="<?php print $classes; ?> clearfix"<?php print $attributes; ?>>
<?php if ($user_picture || !$page || $display_submitted): ?>
<header>
<?php print $user_picture; ?>
<?php print render($title_prefix); ?>
<?php if (!$page): ?>
<h2<?php print $title_attributes; ?>><a href="<?php print $node_url; ?>"><?php print $title; ?></a></h2>
<?php endif; ?>
<?php print render($title_suffix); ?>
<?php if ($display_submitted): ?>
<p class="submitted">
<?php print $submitted; ?>
<time pubdate datetime="<?php print $submitted_pubdate; ?>">
<?php print $submitted_date; ?>
</time>
</p>
<?php endif; ?>
</header>
<?php endif; ?>
<div class="content"<?php print $content_attributes; ?>>
<?php
// We hide the comments, tags and links now so that we can render them later.
hide($content['comments']);
hide($content['links']);
hide($content['field_tags']);
$slice_colors = array(
"New" => "blue",
"Processing" => "yellow",
"Accepted" => "green",
"Rejected" => "red",
);
$foo = array_values($content['batch_data']);
//dsm($foo);
$ints = array();
foreach ($foo as $f) {
$ints[] = (int)$f;
}
$keys = array_keys($content['batch_data']);
foreach ($keys as $k) {
$slices[] = array("color" => $slice_colors[$k]);
}
$chart = array(
'#type' => 'chart',
'#title' => 'Batch',
'#chart_type' => 'pie',
'#slices' => $slices,
);
$chart['pie_data'] = array(
'#type' => 'chart_data',
'#title' => 'Hello, world!',
'#labels' => array_keys($content['batch_data']),
'#data' => $ints,
);
print render($chart);
print render($content);
print uploader_batch_view_render_documents($node->nid);
print theme('pager', array('tags' => array()));
?>
</div>
<!-- /.content -->
<?php if (!empty($content['field_tags']) || !empty($content['links'])): ?>
<footer>
<?php print render($content['field_tags']); ?>
<?php print render($content['links']); ?>
</footer>
<?php endif; ?>
<?php print render($content['comments']); ?>
</article><!-- /.node -->
Chapter 2:
REST
Modules required
- services
- rest_server
Services REST provides CRUD operations for:
- Nodes
- Users
- Taxonomy
- Comments
- Files
- System variables
- Views (with services_views)
Create a custom Resource if:
- you want to access objects other than those
- you created a custom table
- you need to adjust the HTTP Response codes
hook_services_resources
/**
* Implements hook_services_resource()
* @return array
* @todo stop returning a 401 for malformed JSON. Return 406 instead.
*/
function uploader_resource_services_resources() {
return array(
'uploader' => array(
'operations' => array(
'retrieve' => array(
'help' => 'handles the RETRIEVE method (GET)',
'callback' => '_uploader_resource_retrieve',
'access callback' => '_uploader_resource_access',
'access arguments' => array('view'),
'access arguments append' => FALSE,
'args' => array(
array(
'name' => 'batchID',
'type' => 'int',
'description' => 'batchID to check on',
'source' => array('path' => '0'),
'optional' => FALSE,
),
),
),
'create' => array(
'help' => 'handles the CREATE method of the uploader service',
'callback' => '_uploader_resource_create',
'access callback' => '_uploader_resource_access',
'access arguments' => array('view'),
'access arguments append' => FALSE,
'args' => array(
array(
'name' => 'documents',
'type' => 'struct',
'description' => 'upload JSON payload',
'source' => 'data',
'optional' => FALSE,
),
),
),
),
),
);
}
/**
* Implements the REST retrieve callback:
*
* When the REST server receives a GET request on this resource, this callback
* will fire.
*
* @param $batchID
* @return array
*
*/
function _uploader_resource_retrieve($batchID) {
Global $base_url;
$output = array();
// for some reason is_int doesn't catch strings that include integers
// but start with a non-integer, such as "abc11".
$pattern = '/\D/';
preg_match($pattern, $batchID, $matches);
if ($matches[0]) {
$output['Error'] = "Invalid Batch ID: $batchID";
}
else {
$batch = node_load($batchID);
if ($batch) {
$created = date(DATE_RFC2822, $batch->created);
$count = $batch->field_number_of_documents['und'][0]['value'];
$status = $batch->field_status['und'][0]['value'];
// convert numeric status into human-readable value
$status_human = db_query("SELECT ds.status_text from {document_statuses} ds where ds.status_id = :sid", array(":sid" => $status))->fetchField();
// count the number of non-new status documents from this batch
$non_new = db_query("SELECT d.document_id from {documents} d WHERE d.batch_id=:bid AND d.status != :st", array(
":bid" => $batchID,
":st" => '1'
))->rowCount();
$output['Batch ID'] = $batch->nid;
$output['Location'] = $base_url . "/rest/documents/" . $batchID . ".json";
$output['Upload Timestamp'] = $created;
$output['Status'] = $status_human;
$output['Comments'] = "$non_new / $count Processed";
}
else {
drupal_add_http_header('Status', '500 Error');
$output['Error'] = "Batch #$batchID not found.";
}
}
return $output;
}
REST Retrieve
REST Create
/**
* Implements the REST create hook:
*
* When the REST server receives a POST request on this resource, this callback
* will fire.
*
* @param $documents
* @return array
*
* @todo break the node saving process into a hook_insert (or hook_node_insert)?
* @todo on success, instead of generating the output here, use the retrieve callback.
*/
function _uploader_resource_create($documents) {
// Check to see if $documents is defined. If the payload was valid JSON,
// it will be. Otherwise, it'll be empty? Looks like malformed JSON
if (is_null($documents)) {
drupal_add_http_header('Status', '406 Not Acceptable');
}
Global $base_url;
// create the batch, save it. $batch becomes blessed as a node.
$output = array();
$batch = new stdClass();
$batch->type = "batch";
$batch->title = "Batch Upload";
$batch->field_status['und'][0]['value'] = "1";
if (isset($documents['documents'])) {
$batch->field_number_of_documents['und'][0]['value'] = count($documents['documents']);
$output['Status'] = count($documents['documents']) . " Documents Received";
}
else {
$batch->field_number_of_documents['und'][0]['value'] = 0;
$output['Status'] = "Error: No Documents Received";
}
node_save($batch);
$location = $base_url . "/rest/documents/" . $batch->nid . ".json";
try {
drupal_add_http_header("Location", $location);
drupal_add_http_header('Status', '202 Accepted');
if (isset($documents['documents'])) {
foreach ($documents['documents'] as $document) {
// entity name
$document['entity']['0']['name'] = htmlentities($document['entity']['0']['name']);
// Certifying Agent
$document['fields']['0']['value'][0] = htmlentities($document['fields']['0']['value'][0]);
// Certification Number
$document['fields']['1']['value'][0] = htmlentities($document['fields']['1']['value'][0]);
// Regulator
$document['fields']['2']['value'][0] = htmlentities($document['fields']['2']['value'][0]);
$json_d = json_encode($document);
$json_d = html_entity_decode($json_d);
$json_d = stripslashes($json_d);
$docid = db_insert('documents')
->fields(array(
'document_text' => $json_d,
'batch_id' => $batch->nid,
'id' => $document['id'],
'status' => '1',
))
->execute();
// add an item to the queue to go do something with later
// which will be implemented in another module
$queue = DrupalQueue::get('uploader_documents');
$queue->createItem(array(
'document_id' => $docid,
'document_text' => $json_d
));
}
}
}
catch
(Exception $e) {
drupal_add_http_header('Status', '500 Error');
$output['Error'] = $e->getMessage();
}
$output['Batch ID'] = $batch->nid;
$output['Location'] = $location;
$output['Upload Timestamp'] = date(DATE_RFC2822);
return $output;
}
REST "Clients"
- cURL
- on the command line
- underpins everything
- Guzzle
- lightweight PHP library
- install with Composer
- https://github.com/guzzle/guzzle
- drupal_http_request
- If you're reading this, you've already got it.
- Your browser
- Chrome REST Console
- Firefox POSTER
- or, you know, the location bar
Chapter 3:
AMQP
What is AMQP?
There are innumerable ways to implement AMQP.
But all of them will involve decoupling individual components.
RabbitMQ
A well supported AMQP compliant message broker.
- Comes with an admin front-end! Cunning!
- https://www.rabbitmq.com/
Exchanges
Types of Exchange:
- Direct
- Headers
- Fanout
- Topic
We're only looking at "Topic" today.
Queues
Routing Keys
- A label separating this message from other messages
- Only words and dots are allowed; you can string together long patterns like big.red.dog:
- big.*.dog
- fluffy.#
Bindings
- Combination of an Exchange and a Routing Key
- Queues can support more than one binding
Bindings
All finished!
AMQP Modules needed
- message_broker
- message_broker_amqp
- Your custom Module
Publisher
/**
* @param $object
* @return bool
*/
function query_publisher_send_request($object)
{
$data = $object->value();
$options = array();
$options['routing_key'] = variable_get('query_publisher_mvpams_routing_key');
$options['reply_to'] = variable_get('query_publisher_mvpams_reply_to');
$options['correlation_id'] = $data->nid;
query_publisher_publish($data->field_payload['und'][0]['value'], variable_get('query_publisher_mvpams_exchange'), $options);
return TRUE;
}
/**
* @param $message
* @param $exchange
* @param $options
*/
function query_publisher_publish($message, $exchange, $options)
{
$broker = message_broker_get();
try {
$broker->sendMessage($message, $exchange, $options);
watchdog('AMQP', 'Sent Query AMQP message to %exchange', array('%exchange' => $exchange), WATCHDOG_NOTICE);
} catch (InvalidArgumentException $e) {
watchdog('AMQP', 'AMQP Query Error (%code): %message', array(
'%code' => $e->getCode(),
'%message' => $e->getMessage()
),
WATCHDOG_ERROR);
}
}
Consumer
/**
* Implements hook_message_broker_consumers
* @param $self_name
* @return array
*/
function query_consumer_message_broker_consumers($self_name) {
$consumers = array();
$consumers['Query Request'] = array(
'queue' => variable_get('query_consumer_request_queue'),
'callback' => 'query_consumer_request_handler',
);
return $consumers;
}
/**
* Callback function for the request queue consumer.
*
* This function is responsible for taking a request for validation from ID
* and creating a node with the information contained within.
*
* @param $request
* @param $ack
*/
function query_consumer_request_handler($request, $ack) {
watchdog('amqp',"Query Consumer activated.");
$node = new stdClass;
$node->type = "verification_query";
$body = json_decode($request->body);
$b_array = array();
$b_array['canonical'] = array();
$b_array['exact'] = array();
foreach ($body->{'canonical'} as $can) {
foreach (array_keys(get_object_vars($can)) as $key) {
$b_array['canonical'][$key] = $can->$key;
}
}
foreach ($body->{'exact'} as $ex) {
foreach (array_keys(get_object_vars($ex)) as $key) {
$b_array['exact'][$key] = $ex->$key;
}
}
$node->title = $b_array['canonical']['entity.name'];
$node->field_organization_id['und'][0]['value'] = $b_array['exact']['org id'];
$node->field_country['und'][0]['value'] = $b_array['exact']['schema.countrycode'];
$node->field_reply_to['und'][0]['value'] = $request->get("reply_to");
$node->field_payload['und'][0]['value'] = $request->body;
try {
node_save($node);
watchdog('amqp', 'Created Verification Query Node %nid', array('%nid' => $node->nid), WATCHDOG_INFO);
$ack();
}
catch (Exception $e) {
watchdog('amqp', 'Failed to create Verification Query: %error', array('%error' => $e->getMessage()), WATCHDOG_ERROR);
}
}
Starting the Consumer
- message_broker_amqp includes a "drush cons" command
- shell script wrapper to restart drush cons if necessary
- pkill cron job if your behavior gets flaky
- Upstart if you wanna get fancy
drush cons
ldpm-laptop,[/path/to/drupal/sites/your.site],11:19 AM%> drush cons
Connected successfully to the AMQP message broker at turtle.rmq.cloudamqp.com:5672
Starting all consumers ...
(use "screen" to detach)
"drushalive.sh"
#!/bin/bash
ORGANIC="/var/www/afilias_d7/htdocs/sites/mvp.organic"
NGO="/var/www/afilias_d7/htdocs/sites/iddemo.info"
if [ $1 == "ORGANIC" ]; then
cd $ORGANIC
else
cd $NGO
fi
until drush cons; do
echo "Drush crashed with exit code $?. Respawning.." >&2
sleep 1
done
(use "screen" to detach)
Upstart: /etc/init/drushalive.conf
# drushalive - drushalive process script
description "Drush Alive Script for Organic"
start on starting tty1 and net-device-up IFACE!=lo
stop on starting rc RUNLEVEL=[016]
console output
respawn
respawn limit 10 20
chdir /var/www/afilias_d7/htdocs/sites/mvp.organic
# What to execute
script
if [ -r /etc/default/drushalive ]; then
. /etc/default/drushalive
fi
exec su -c '/usr/bin/drush cons' dpk >>/var/log/drush/drushorganic.log 2>&1
# start-stop-daemon --start -c $RUN_AS --exec $DAEMON -- $DAEMON_OPTS
end script
pkill: if drush gets twitchy
[someuser@yourhost ~]$ crontab -l
* */2 * * * /usr/bin/pkill -f drush.php
The combination of the keep-alive method (either the script or Upstart) and pkill should keep the consumer running all the time even if it errors out.
Thank you!
ldpm @ d.o
@ldpm
lmiller@afilias.info
Render Unto Caesar
By Lawrence Miller
Render Unto Caesar
- 636