2012/04/02

Building a PHPSuite while napping (pt. 1)


Netsuite's existing integration solution for PHP, the PHP Toolkit, is plenty powerful, but the minimalist documentation and disappointing end-product (SOAP solutions are so 2006) make it a bit of a PITA. The following came from some attempts to benchmark the existing SOAP solution against Netsuite's relatively recent RESTlet service (the results of which I will post another time. Long story short, REST really is the only solution you should for consideration)



A complete reworking of the PHPToolkit as a REST service is a large feat, though hardly impossible, but outside of the scope of this work. ATM I am focusing my efforts on creating a more perfect way to access Netsuite's extensive saved search featureset directly from PHP. The early results are promising!

To start, here is a basic RESTlet shell:

function rstGet( datain ) {
var fruit = {
'status' : 'success',
'payload' : {}
};
var request_args = ['method','rq','cf'];
try {
// extract args from request
var request_fields = {};
for (var ra in request_args) {
request_fields[request_args[ra]] = datain[request_args[ra]];
if (typeof request_fields[request_args[ra]] == 'undefined' || request_fields[request_args[ra]] == null) {
throw nlapiCreateError( 'Invalid request', request_args[ra] + ' is not specified.' );
}
}
// decode args resulting in json string. compare json againt hash, finally convert from json to javascript object
var args = decodeURIComponent( Base64.decode( request_fields['rq'] ) );
if (nlapiEncrypt( args, 'sha1' ) != request_fields['cf']) {
// TODO - not sure that the hashing algorithm is reliable, more testing is needed.
// throw nlapiCreateError( 'Invalid request', 'Tua cinis non aequare.' );
}
args = (args.length > 0) ? jsonParse( args ) : {};
// interpret method
switch (request_fields['method'].toLowerCase()) {
default:
throw nlapiCreateError( 'Invalid request', 'Method "' + request_fields['method'] + '" is not recognized.', true );
}
}
catch (exc) {
fruit.status = 'failure';
fruit.payload = handleException( exc );
}
return fruit;
}
function rstPost( ){ }
function rstPut( ){ }
function rstDelete( ){ }
function handleException( error ) {
var message;
if (error instanceof nlobjError) {
message = {
"code" : error.getCode(),
"id" : error.getId(),
"details" : error.getDetails(),
"internalId" : error.getInternalId(),
"userEvent" : error.getUserEvent(),
"stackTrace" : error.getStackTrace()
};
} else {
message = {
"code" : 'unexpected error',
"details" : ((typeof error == 'object') ? error.toString() : error)
};
}
nlapiLogExecution( 'ERROR', message.code, message.details );
return message;
}

Not much going on here, just an empty shell to get us started. The script looks for three properties immediately, method will be compared against our "whitelist", only functionality we explicitly allow will be executed, any other request is promptly put down. rq will be our argument-set. Finally cf is a sha1 hash of the argument-set from the client. A good security feature, but atm php's hashing functions produce distinct results from nlapiEncrypt(). something I intend to revisit later. We setup a return object right away with only two properties: status and payload. The status can be checked for "success" by the caller and extract the result object from the payload, or if an error occurs the payload becomes an error object with relevant code and message. This is a very brief, predictable bundle, but by no means limited as we will see.

Now we need to a searching function:

function performRecordSearch( args ) {
var results = [];
if (!args.hasOwnProperty( 'type' )) {
throw nlapiCreateError( 'Searching record', 'type is not defined.' );
}
var srch_filters = [];
var srch_columns = [];
// generate record search filters
if (args.hasOwnProperty( 'filters' ) && args.filters.length > 0) {
var modifiers = getFilterModifiers();
for (var af in args.filters) {
try {
var filter = args.filters[af];
var filterValueA = filter.value;
var filterValueB = filter.value2;
// TODO - the fldtype is an off the cuff solution, need to find something more elegant.
if (filter.hasOwnProperty( 'fldtype' ) && filter.fldtype == 'datetime') {
if (filterValueA != null) { filterValueA = nlapiStringToDate( filterValueA ); }
if (filterValueB != null) { filterValueB = nlapiStringToDate( filterValueB ); }
}
var srchfilter = new nlobjSearchFilter( filter.name, filter.join, filter.operator, filterValueA, filterValueB );
if (filter.hasOwnProperty( 'formula' ) && filter.formula != null) {
srchfilter.setFormula( filter.formula );
}
srch_filters.push( srchfilter );
// check for any search modifiers and put into play
for (var m in modifiers) {
if (filter.hasOwnProperty( m ) && filter[m] != null) {
modifiers[m]( srch_filters[srch_filters.length-1], filter[m] );
}
}
} catch (err) {
nlapiLogExecution( 'ERROR', 'Invalid filter', (typeof filter == 'object' ? filter.toString() : filter) );
continue;
}
}
}
// generate record search columns
if (args.hasOwnProperty( 'columns' ) && args.columns.length > 0) {
for (var ac in args.columns) {
try {
var column = args.columns[ac];
var srchcol = new nlobjSearchColumn( column.name, column.join, column.summary );
// https://system.netsuite.com/help/helpcenter/en_US/Output/Help/SuiteFlex/SuiteScript/SSAPI_nlobjSearchColumn.html#1412088
if (column.hasOwnProperty( 'formula' ) && column.formula != null) {
srchcol.setFormula( column.formula );
}
if (column.hasOwnProperty( 'functionid' ) && column.functionid != null) {
srchcol.setFunction( column.functionid );
}
if (column.hasOwnProperty( 'label' ) && column.label != null) {
srchcol.setLabel( column.label );
}
if (column.hasOwnProperty( 'sort' ) && column.sort != null) {
srchcol.setSort( !!column.sort );
}
srch_columns.push( srchcol );
}catch (err) {
nlapiLogExecution( 'ERROR', 'Invalid column', (typeof column == 'object' ? column.toString() : column) );
continue;
}
}
}
if (srch_filters.length < 1 && srch_columns.length < 1 && (!(args.hasOwnProperty( 'id' )) || args.id == null)) { throw nlapiCreateError( 'Searching record', 'could not locate valid criteria' ); } nlapiLogExecution( 'AUDIT', 'search call', 'type: '+args.type+' [id: '+args.id+']' ); // execute search var search_results = nlapiSearchRecord( args.type, args.id, srch_filters, srch_columns ); nlapiLogExecution( 'AUDIT', 'search results', '['+(typeof search_results)+'] '+'('+(search_results != null && search_results.hasOwnProperty( 'length' ) ? search_results.length : 0)+')' ); if (search_results && search_results.length > 0) {
var columns = search_results[0].getAllColumns();
for (var sr in search_results) {
var result = {
'type' : search_results[sr].getRecordType(),
'id' : search_results[sr].getId(),
'columns' : []
};
for (var c in columns) {
var title = columns[c].getLabel();
if (typeof title == 'undefined' || title == null) {
title = columns[c].getName();
}
var values = {
'name' : title,
'value' : search_results[sr].getValue( columns[c] ),
'text' : search_results[sr].getText( columns[c] )
};
result['columns'].push( values );
}
results.push( result );
}
}
return results;
}

This is a bit of a monstrosity, with a couple of hacks that I will point out. Immediately we are looking for the four primary components of a suitesearch: record type, record id (optional), search filters (optional) and search columns (optional). We'll take anything we can get, but we need at minimum the record type and an id, or a filter, or a column. If the request doesn't meet that feeble requirement we simply reject is as invalid.

Start off by checking for any potential filters and sorting those out. We can chuck most any value at nlobjSearchFilter, but it has some difficulty processing dates. An immediate solution is to hack in an optional argument in our filter record, "fldtype". If present, and set to date, we know to use nlapiStringToDate() to prevent any datatype errors. Once the filter is created we explore a little black magic. First we check for any potential formulas. Then we check for any requested modifiers. We need some definition here, provided by getFilterModifiers()

function getFilterModifiers() {
var mods = {
'not' : function( obj, nularg ){obj.setNot( 'T' );},
'leftparens' : function( obj, c ){if (typeof c == 'undefined' || c == null || c < 1) {c = 1;}if (c > 3) {c = 3;}obj.setLeftParens( c );},
'rightparens' : function( obj, c ) {if (typeof c == 'undefined' || c == null || c < 1) {c = 1;}if (c > 3) {c = 3;}obj.setRightParens( c );},
'or' : function( obj, nularg ){obj.setOr( 'Or' );}
};
return mods;
}

(as of this writing) the four functions above (setNot(), setLeftParens(), setRightParens() and setOr()) are undocumented, but were confirmed by evan_goldberg on the Netsuite User Community.

Now we deal with columns very similarly to filters: grab any potential arguments, instantiate the object and implement any potential mutators (formula, function, label & sort).

Finally we have everything we need in place. We can run the search and collect the results. Lastly we need to wrap the search results to return to the caller. We create a new object, results, that contains the record identity (type and id) and the text and/or values for each column the search returned.

With this heavyweight in place all we have to do is hook the function into our whitelist. Go back to the rstGet() function and update the switch statement:

switch (request_fields['method'].toLowerCase()) {
case 'search':
fruit.payload = performRecordSearch( args );
break;
default:
throw nlapiCreateError( 'Invalid request', 'Method "' + request_fields['method'] + '" is not recognized.', true );
}

Thats all there is to it! Now we have a supernatural Saved Search implementation at our finger tips, ready to be accessed by any REST client, which i'll cover in my next post.

No comments:

Post a Comment