[RFC] Fun with #validate: XMLRPC validation for an intranet/customer userbase

bdragon - June 30, 2006 - 18:15

Hi!
I just wrote a document dealing with a problem I had to work out recently involving a custom user registration procedure.
I'd like to hear any comments on it regarding its usefulness and/or uselessness.
-----
For the past six months, I have been busy implementing a Drupal-based customer portal for a smallish ISP. (smallish being 60-70k users at the moment)

This involves a lot more integration work than any normal portal would, and has unique requirements when it comes to user registration.

The ISP in question wishes to allow existing customers to sign up using their billing number and their name, which are printed on the billing statements the users get every month in the post.

At the ISP, my team and the ISP's team set up an XMLRPC server to allow the Drupal side to validate customers without having access to the customer database. This server accepts a billing number and billing name, checks to ensure the data is correct, and sends back information about that customer that we then use on the Drupal side to autoconfigure the user's account. Things like whether the user has phone service with the ISP and what town to show weather for.

This happens once during user creation, and can also be done by the user or an administrator to reset the user defaults in an automatic fashion.

If anyone who reads this has ever been in a similar situation, I hope the following helps you.

The Drupal 4.7 forms API is a very interesting beast. It is VERY flexible, once you learn why it looks like it does. One underdocumented (in my opinion) property of a form element is the '#validate' property. It happens after the submit button is clicked, and before the results are committed. You can do some very useful things during that time.

Let's write a module.

$ gvim isp_user.module

<?php
/**
* Implementation of hook_help().
*/
function isp_user_help($section) {
   
$output = '';
   
    switch(
$section){
        case
'admin/modules#description':
           
$output = t("ISP User registration customizations");
            break;
        case
'admin/settings/isp_user':
           
$output = t("Settings for this module affect the way Drupal user accounts interoperate with other ISP systems.");
            break;
    }
    return
$output;
}
?>

Your basic hook_help(). Notice the 'admin/settings/isp_user' part. We will be making a settings page later.

<?php
/**
* Implemenation of hook_perm().
*/
function isp_user_perm() {
    return array(
'Bypass validation checks', 'Administer isp_user');
}
?>

Sometimes we need to bypass the system. Having a permission in place will save headaches if something ever goes wrong (and to create accounts for the ISP's employees, who don't necessarily get their internet service through said ISP ;)

Coming up next is the user hook, where we will declare our intentions to muck about with the user form.

<?php
/**
* Implemenation of hook_user().
* Side effects:
* 1.) If the operation is insert, inject the default links into the bookmarks module database.
* 2.) Prevents transient flags on the user object from being saved to the database.
* 3.) Modifies pieces of the user registration/edit form to implement things needed by our ... interesting... user creation process.
*/
function isp_user_user($op, &$edit, &$account, $category = NULL) {

   
/*
     * Insert bookmarks for new users.
     * The users are allowed to delete them, this is only a convienence feature.
     */
   
if($op == 'insert') {
       
db_query("INSERT INTO {bookmarks} VALUES (%d,'%s','%s')",$account->uid,'http://google.com','Google');
    }

   
/*
     * Edit user page OR user register page.
     */
   
if(($op == 'form' && $category == 'account')||$op == 'register') {
       
       
/*
         * Billing information.
         *
         */
       
$form['billing_info'] = array(
           
'#type' => 'fieldset',
           
'#title' => t('Account Information'),
           
'#weight' => -5, // Float to the top
       
);
       
$form['billing_info']['billing_id'] = array(
           
'#type' => 'textfield',
           
'#title' => t('Billing Account #'),
           
'#size' => 20,
           
'#maxlength' => 20,
           
'#default_value' => $edit['billing_id'],
           
'#description' => t('Enter your account number from your statement.'),
        );
       
$form['billing_info']['billing_name'] = array(
           
'#type' => 'textfield',
           
'#title' => t('Name on statement'),
           
'#size' => 30,
           
'#maxlength' => 40,
           
'#default_value' => $edit['billing_name'],
           
'#description' => t('Enter your name as shown on your statement.'),
        );

       
$form['billing_info']['billing_revalidate'] = array(
           
'#type' => 'checkbox',
           
'#title' => t('Refresh services'),
           
'#default_value' => false,
           
'#description' => t('Check this box if you have added or removed any services.'),
        );

       
$form['billing_info']['billing_bypass_check'] = array(
           
'#type' => 'value',
           
'#title' => t('Bypass validation checks'),
           
'#value' => false,
           
'#default_value' => false,
           
'#description' => t('Check this box to bypass validation checks.'),
        );
       
        if(
$op=='register') {
           
// Force validation.
           
$form['billing_info']['billing_revalidate']['#type'] = 'value';
           
$form['billing_info']['billing_revalidate']['#value'] = true;
           
$form['billing_info']['billing_revalidate']['#default_value'] = true;
        }

        if(
user_access('Bypass validation checks')) {
           
// Allow skipping revalidation checks.
           
$form['billing_info']['billing_bypass_check']['#type'] = 'checkbox';
            unset(
$form['billing_info']['billing_bypass_check']['#value']);
        }

       
$form['#validate']['isp_user_automation'] = array();

        if(
$account){
           
$form['billing_info']['#validate'][] = array(
               
'isp_user_changecheck' =>  array($account->billing_id,$account->billing_name),
            );
        }
           
        return
$form;
    }

    else if((
$op == 'insert' || $op == 'update') && $category == 'account') {
       
// This isn't actually saved to the database.
       
$edit['billing_revalidate'] = NULL;
       
$edit['billing_bypass_check'] = NULL;
    }
       
}
?>

This code block was quite odd in places, but I hope you are starting to see what I am getting at here.

The first trick is how we inject bookmarks during the insert operation. (used by the Bookmarks module.) While unrelated to the rest of the code, it is a good demonstration of where to apply one-shot settings during user registration.

The second trick is the fact that we can change pieces of the form at any time during the call. So we set up defaults and then check whether overrides are in place, and modify the form accordingly.

<?php
       
if($op=='register') {
           
// Force validation.
           
$form['billing_info']['billing_revalidate']['#type'] = 'value';
           
$form['billing_info']['billing_revalidate']['#value'] = true;
           
$form['billing_info']['billing_revalidate']['#default_value'] = true;
        }
?>

This makes sure we "revalidate" during registration. "Revalidation" in this sense means "Reset everything to defaults, based on the customer info from the validation server"

<?php
       
if(user_access('Bypass validation checks')) {
           
// Allow skipping revalidation checks.
           
$form['billing_info']['billing_bypass_check']['#type'] = 'checkbox';
            unset(
$form['billing_info']['billing_bypass_check']['#value']);
        }
?>

And of course, we need to allow administrators to create accounts while bypassing the validator.

Next we have the most important piece: Declaring our #validate callbacks.

<?php
        $form
['#validate']['isp_user_automation'] = array();

        if(
$account){
           
$form['billing_info']['#validate'][] = array(
               
'isp_user_changecheck' =>  array($account->billing_id,$account->billing_name),
            );
        }
?>

One of the things I screwed up the most when writing this module was writing this block of code. If you screw up here, you end up with a broken form and little-to-no usefull information in the backtrace. Be careful when you add your callbacks, because it's a pain to debug.

The first line adds a validation callback for the entire form.

The second part adds a check for modified information, which of course is only useful for pre-existing accounts (hence the if($account).)
Notice we are passing the old billing_id and billing_name to our callback.

While these two pieces of code look different, they are doing the same thing: registering a callback.

The really neat thing is the ability to have multiple callbacks on the same object. On the second piece of code, note the []. I'm appending a callback to the array of callbacks that #validate holds.

(Please be aware that the stuff I just said about #validate was mostly guesswork on my part. In my opinion, the API documentation for #validate really needs to be expanded.)

<?php
   
else if(($op == 'insert' || $op == 'update') && $category == 'account') {
       
// This isn't actually saved to the database.
       
$edit['billing_revalidate'] = NULL;
       
$edit['billing_bypass_check'] = NULL;
    }
?>

And here, we're making sure that our toggles aren't being saved to the user account (as they are used by the form, not the actual account)

Now, to write our callbacks.

<?php
function isp_user_changecheck($form,$oldid,$oldname) {
    global
$form_values;
    if(!
$form_values['billing_bypass_check'] && (!($form_values['billing_id'] === $oldid) || !($form_values['billing_name'] === $oldname))) {
       
form_set_value($form['billing_revalidate'],true);
    }
?>

Notice how we compare our form values to our account values.
If the user changed their billing info, we need to force a revalidate. (If they didn't, we don't want to revalidate anyway, as it would keep resetting the user's settings.)

<?php
function isp_user_automation($form_id, $form_values, $form) {
    if(
$form_values['billing_revalidate']) {
       
$id = $form_values['billing_id'];
       
$name = $form_values['billing_name'];
       
        if(
variable_get('isp_user_xmlrpc_enable',0)==1) {
           
$response = xmlrpc(variable_get('isp_user_xmlrpc_endpoint','http://localhost/invalid.asp'),'ISPServer.Validate',$id, $name);
            if(
variable_get('isp_user_xmlrpc_debug',0)==1) {
               
// Additional debugging code.
               
var_dump($response);
                if(
$response==false) {
                   
var_dump(array("Errorno/Errormsg",xmlrpc_errno(),xmlrpc_error_msg()));
                }
            }
            if(
$response==false) {
               
//@@@ serious error -- server down or something....
               
form_error($form['billing_info']['billing_id'],t('Sorry, there was an error with our server. Please try again later.'));
            }
            else {
                if(
$response['Valid'] == false) {
                   
form_set_error('billing_info',t('Invalid account number and/or name. Please make sure you typed them correctly.'));
                }
                else {
                    if(
$response['hasWebmail'] == true) {
                       
form_set_value($form['isp_webmail'],1);
                    }
                   
form_set_value($form['isp_postal_code'],$response['PostalCode']);
                }
            }
        }
        else {
           
// Validation disabled.
           
watchdog('user','Billing Validation Disabled!',WATCHDOG_WARNING);
        }
    }
}
?>

The automation function does it all. It calls up the validation server and gets the useful information, and proceeds to fill in the form with some nice custom defaults. The actual code in use fills in many more things, but the simplest way to transfer stuff from the XMLRPC response to the form is shown by the PostalCode line.
Also note the debugging stuff. We had at one point needed to check what was happening behind the scenes, and left in the debugging code, protected by a module setting. Just in case we needed it again.
And we're logging a warning if the admin had turned off validation completely for some reason.

Finally, our settings page.

<?php
function isp_user_settings() {
   
$form['XML-RPC'] = array(
       
'#type' => 'fieldset',
       
'#title' => t('XML-RPC Settings'),
       
'#weight' => -5,
    );
   
   
$form['XML-RPC']['isp_user_xmlrpc_endpoint'] = array(
       
'#type' => 'textfield',
       
'#title' => t('XML-RPC Endpoint'),
       
'#default_value' => variable_get('isp_user_xmlrpc_endpoint','http://localhost/invalid.asp'),
       
'#size' => 60,
       
'#maxlength' => 255,
       
'#description' => t('Set the XML-RPC endpoint to check new user accounts against.'),
    );

   
$form['XML-RPC']['isp_user_xmlrpc_enable'] = array(
       
'#type' => 'checkbox',
       
'#title' => t('Enable Validation'),
       
'#default_value' => variable_get('isp_user_xmlrpc_enable',0),
     
'#description' => t('Enable/disable validation globally. WARNING: Disabling this will turn off the new user validation code for the ENTIRE site, allowing users to create accounts not tied to billing numbers!'),
    );

   
$form['XML-RPC']['isp_user_xmlrpc_debug'] = array(
       
'#type' => 'checkbox',
       
'#title' => t('Debug'),
       
'#default_value' => variable_get('isp_user_xmlrpc_debug',0),
       
'#description' => t('Enable additional code to help diagnose problems'),
    );

return
$form;
}
?>

The more configurable the module is, the more things that can be fixed when they break down without having to change the code.
We let the portal administrator configure the location of the validation server (in case it ever changes), whether to enable or disable validation completely, and whether to enable debugging.

Before deploying the portal, we will probabaly move this out to the config file, because it's really too powerful to be in a simple settings page, but for testing purposes, having it there is OK.

That's it for the isp_user.module file.

The second part of the process is all of the integration modules. Things like the custom weather module I wrote for the portal.
The only part of the process of writing an integration module relevant to this document is the user hook:

$ gvim isp_user.module

<?php
/**
* Implemenation of hook_user().
*/
function isp_weather_user($op, &$edit, &$account, $category = NULL) {
    if(
$op == 'form' && $category == 'account') {
       
       
$form['isp_postal_code'] = array(
           
'#type' => 'textfield',
           
'#title' => t('Zip code'),
           
'#size' => 20,
           
'#maxlength' => 20,
           
'#default_value' => $edit['isp_postal_code'],
           
'#description' => t('If your zip code is incorrect, please enter the correct zip code.'),
        );
           
        return
$form;
    }

   
// Create form slots for our custom user variables.
   
else if($op == 'register') {
       
$form['isp_postal_code'] = array(
           
'#type' => 'value',
           
'#value' => variable_get('isp_weather_defaultcode','?????'), // Note to reader: In the actual code, the default would be filled in.
       
);

        return
$form;
    }
}
?>

The main thing to do is to declare your form normally during everything but registration, and to declare everything as the value type during registration so the validation stuff in isp_user.module can fill it in.

The use of the 'value' type is quite useful in this circumstance.

That brings us to the end of this document. I hope you found this useful.
------------

Too confusing? Too simplified? Too complex? Just right? Noticed a bug?
I'd love to hear about it.

Thanks,

--Brandon Bergren

Thanks

ximo - February 17, 2007 - 21:09

I'm not in the position to say whether it's too this or too that, but it's interesting none the less to see how you dealt with this.

 
 

Drupal is a registered trademark of Dries Buytaert.