app/lib/communicator/communicator.js

/**
 * @module communicator
 */

const request = require( 'request' );
const Auth = require( 'request/lib/auth' ).Auth;
const TError = require( '../custom-error' ).TranslatedError;
const config = require( '../../models/config-model' ).server;
const debug = require( 'debug' )( 'openrosa-communicator' );
const parser = new require( 'xml2js' ).Parser();
const TIMEOUT = config.timeout;

/**
 * Gets form info
 *
 *
 * @static
 * @param { module:survey-model~SurveyObject } survey - survey object
 * @return { Promise<module:survey-model~SurveyObject> } a Promise that resolves with a survey object with added info
 */
function getXFormInfo( survey ) {
    if ( !survey || !survey.openRosaServer ) {
        throw new Error( 'No server provided.' );
    }

    return _request( {
        url: getFormListUrl( survey.openRosaServer, survey.openRosaId, survey.customParam ),
        auth: survey.credentials,
        headers: {
            cookie: survey.cookie
        }
    } ).then( formListXml => _findFormAddInfo( formListXml, survey ) );
}

/**
 * Gets XForm from url
 *
 * @static
 * @param  { object } survey - survey object
* @return { Promise<module:survey-model~SurveyObject> } a Promise that resolves with a survey object with added XForm
 */
function getXForm( survey ) {
    return _request( {
        url: survey.info.downloadUrl,
        auth: survey.credentials,
        headers: {
            cookie: survey.cookie
        }
    } ).then( xform => {
        survey.xform = xform;

        return Promise.resolve( survey );
    } );
}


/**
 * Obtains the XForm manifest
 *
 * @static
 * @param {module:survey-model~SurveyObject} survey - survey object
* @return { Promise<module:survey-model~SurveyObject> } a Promise that resolves with a survey object with added manifest
 */
function getManifest( survey ) {
    if ( !survey.info.manifestUrl ) {
        // a manifest is optional
        return Promise.resolve( survey );
    } else {
        return _request( {
            url: survey.info.manifestUrl,
            auth: survey.credentials,
            headers: {
                cookie: survey.cookie
            }
        } )
            .then( _xmlToJson )
            .then( obj => {
                survey.manifest = ( obj.manifest && obj.manifest.mediaFile ) ? obj.manifest.mediaFile.map( file => _simplifyFormObj( file ) ) : [];

                return survey;
            } );
    }
}

/**
 * Checks the maximum acceptable submission size the server accepts
 *
 * @static
 * @param { module:survey-model~SurveyObject } survey - survey object
 * @return { Promise<string> } promise resolving with max size stringified number
 */
function getMaxSize( survey ) {
    // Using survey.xformUrl is non-standard but the only way for previews served from `?form=URL`.
    const submissionUrl = survey.openRosaServer ? getSubmissionUrl( survey.openRosaServer ) : survey.info.downloadUrl;

    const options = {
        url: submissionUrl,
        auth: survey.credentials,
        headers: {
            cookie: survey.cookie
        },
        method: 'head'
    };

    return _request( options )
        .then( response => response.headers[ 'x-openrosa-accept-content-length' ] );
}

/**
 * @static
 * @param { module:survey-model~SurveyObject } survey - survey object
 * @return { Promise<module:survey-model~SurveyObject> } a promise that resolves with a survey object
 */
function authenticate( survey ) {
    const options = {
        url: getFormListUrl( survey.openRosaServer, survey.openRosaId, survey.customParam ),
        auth: survey.credentials,
        headers: {
            cookie: survey.cookie
        },
        // Formhub has a bug and cannot use the correct HEAD method.
        method: config[ 'linked form and data server' ][ 'legacy formhub' ] ? 'get' : 'head',
    };

    return _request( options )
        .then( () => {
            debug( 'successful (authenticated if it was necessary)' );

            return survey;
        } );
}

/**
 * Generates an Auhorization header that can be used to inject into piped requests (e.g. submissions).
 *
 * @static
 * @param { string } url - URL to request
 * @param { {user: string, pass: string, bearer: string} } [credentials] - user credentials
 * @return { Promise } a promise that resolves with an auth header
 */
function getAuthHeader( url, credentials ) {
    const options = {
        url,
        method: 'head',
        headers: {
            'X-OpenRosa-Version': '1.0',
            'Date': new Date().toUTCString()
        },
        timeout: TIMEOUT
    };

    return new Promise( resolve => {
        // Don't bother making Head request first if token was provided.
        if ( credentials && credentials.bearer ) {
            resolve( `Bearer ${credentials.bearer}` );
        } else {
            // Check if Basic or Digest Authorization header is required and return header if so.
            const req = request( options, ( error, response ) => {
                if ( !error && response && response.statusCode === 401 && credentials && credentials.user && credentials.pass ) {
                    // Using request's internal library we create an appropiate authorization header.
                    // This is a bit dangerous because internal changes in request/request, could break this code.
                    req.method = 'POST';
                    const auth = new Auth( req );
                    auth.hasAuth = true;
                    auth.user = credentials.user;
                    auth.pass = credentials.pass;
                    const authHeader = auth.onResponse( response );
                    resolve( authHeader );
                } else {
                    resolve( null );
                }
            } );
        }
    } );
}

/**
 * getFormListUrl
 *
 * @static
 * @param { string } server - server URL
 * @param { string } [id] - Form id.
 * @param { string } [customParam] - custom query parameter
 * @return { string } url
 */
function getFormListUrl( server, id, customParam ) {
    let query = id ? `?formID=${id}` : '';
    const path = ( server.lastIndexOf( '/' ) === server.length - 1 ) ? 'formList' : '/formList';

    if ( customParam ) {
        query += query ? '&' : '?';
        query += `${config[ 'query parameter to pass to submission' ]}=${customParam}`;
    }

    return server + path + query;
}

/**
 * @static
 * @param { string } server - server URL
 * @return { string } url
 */
function getSubmissionUrl( server ) {
    return ( server.lastIndexOf( '/' ) === server.length - 1 ) ? `${server}submission` : `${server}/submission`;
}

/**
 * Updates request options.
 *
 * @static
 * @param { object } options - request options
 */
function getUpdatedRequestOptions( options ) {
    options.method = options.method || 'get';

    // set headers
    options.headers = options.headers || {};
    options.headers[ 'X-OpenRosa-Version' ] = '1.0';
    options.headers[ 'Date' ] = new Date().toUTCString();
    options.timeout = TIMEOUT;

    if ( !options.headers.cookie ) {
        // remove undefined cookie
        delete options.headers.cookie;
    }

    // set Authorization header
    if ( !options.auth ) {
        delete options.auth;
    } else if ( !options.auth.bearer ) {
        // check first is DIGEST or BASIC is required
        options.auth.sendImmediately = false;
    }

    return options;
}

/**
 * Sends a request to an OpenRosa server
 *
 * @param {{url: string}} options - request options object
 * @return { Promise } Promise
 */
function _request( options ) {
    let error;

    return new Promise( ( resolve, reject ) => {
        if ( typeof options !== 'object' && !options.url ) {
            error = new Error( 'Bad request. No options provided.' );
            error.status = 400;
            reject( error );
        }

        options = getUpdatedRequestOptions( options );

        // due to a bug in request/request using options.method with Digest Auth we won't pass method as an option
        const method = options.method;
        delete options.method;

        debug( `sending ${method} request to url: ${options.url}` );

        request[ method ]( options, ( error, response, body ) => {
            if ( error ) {
                debug( `Error occurred when requesting ${options.url}`, error );
                reject( error );
            } else if ( response.statusCode === 401 ) {
                error = new Error( 'Forbidden. Authorization Required.' );
                error.status = response.statusCode;
                reject( error );
            } else if ( response.statusCode < 200 || response.statusCode >= 300 ) {
                error = new Error( `Request to ${options.url} failed.` );
                error.status = response.statusCode;
                reject( error );
            } else if ( method === 'head' ) {
                resolve( response );
            } else {
                debug( `response of request to ${options.url} has status code: `, response.statusCode );
                resolve( body );
            }
        } );
    } );
}

/**
 * transform XML to JSON for easier processing
 *
 * @param { string } xml - XML string
 * @return {Promise<string|Error>} a promise that resolves with JSON
 */
function _xmlToJson( xml ) {
    return new Promise( ( resolve, reject ) => {
        parser.parseString( xml, ( error, data ) => {
            if ( error ) {
                debug( 'error parsing xml and converting to JSON' );
                reject( error );
            } else {
                resolve( data );
            }
        } );
    } );
}

/**
 * Finds the relevant form in an OpenRosa XML formList
 *
 * @param { string } formListXml - OpenRosa XML formList
 * @param {module:survey-model~SurveyObject} survey - survey object
 * * @return { Promise } promise
 */
function _findFormAddInfo( formListXml, survey ) {
    let found;
    let index;
    let error;

    return new Promise( ( resolve, reject ) => {
        // first convert to JSON to make it easier to work with
        _xmlToJson( formListXml )
            .then( formListObj => {
                if ( formListObj.xforms && formListObj.xforms.xform ) {
                    // find the form and stop looking when found
                    found = formListObj.xforms.xform.some( ( xform, i ) => {
                        index = i;

                        return xform.formID.toString() === survey.openRosaId;
                    } );
                }

                if ( !found ) {
                    error = new TError( 'error.notfoundinformlist', {
                        formId: survey.openRosaId
                    } );
                    error.status = 404;
                    reject( error );
                } else {
                    debug( 'found form' );
                    survey.info = _simplifyFormObj( formListObj.xforms.xform[ index ] );
                    debug( 'survey.info', survey.info );
                    resolve( survey );
                }
            } )
            .catch( reject );
    } );
}

/**
 * Convert arrays property values to strings, knowing that each xml node only
 * occurs once in each xform node in /formList
 *
 * @param { object } formObj - a form object
 * @return { object } a simplified form object
 */
function _simplifyFormObj( formObj ) {
    for ( const prop in formObj ) {
        if ( Object.prototype.hasOwnProperty.call( formObj, prop ) && Object.prototype.toString.call( formObj[ prop ] ) === '[object Array]' ) {
            formObj[ prop ] = formObj[ prop ][ 0 ].toString();
        }
    }

    return formObj;
}

module.exports = {
    getXFormInfo,
    getXForm,
    getManifest,
    getMaxSize,
    authenticate,
    getAuthHeader,
    getFormListUrl,
    getSubmissionUrl,
    getUpdatedRequestOptions
};