// Accessor for Representational State Transfer (RESTful) interfaces.
// Copyright (c) 2015-2017 The Regents of the University of California.
// All rights reserved.
//
// Permission is hereby granted, without written agreement and without
// license or royalty fees, to use, copy, modify, and distribute this
// software and its documentation for any purpose, provided that the above
// copyright notice and the following two paragraphs appear in all copies
// of this software.
//
// IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY
// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
// ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF
// THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF
// SUCH DAMAGE.
//
// THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES,
// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE
// PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND THE UNIVERSITY OF
// CALIFORNIA HAS NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES,
// ENHANCEMENTS, OR MODIFICATIONS.
//
/** Accessor for RESTful interfaces.
* Upon receipt of a trigger input, this accessor will issue an HTTP request
* specified by the inputs. Some time later, the accessor will receive a response
* from the server or a timeout. In the first case, the accessor will produce
* the response (body, status code, and headers) on output ports.
* In the second case, it will produce a nil output on the response port
* and an error.
*
* The accessor does not block waiting for the response, but any additional
* triggered requests will be queued to be issued only after the pending request
* has received either a response or a timeout. This strategy ensures that outputs
* from this accessor are produced in the same order as the inputs that trigger the
* HTTP requests.
*
* The <i>options</i> input can be a string URL (with surrounding quotation marks)
* or an object with the following fields:
* <ul>
* <li> headers: An object containing request headers. By default this
* is an empty object. Items may have a value that is an array of values,
* for headers with more than one value.
* <li> keepAlive: A boolean that specified whether to keep sockets around
* in a pool to be used by other requests in the future. This defaults to false.
* <li> method: A string specifying the HTTP request method.
* This defaults to 'GET', but can also be 'PUT', 'POST', 'DELETE', etc.
* <li> url: A string that can be parsed as a URL, or an object containing
* the following fields:
* <ul>
* <li> host: A string giving the domain name or IP address of
* the server to issue the request to. This defaults to 'localhost'.
* <li> protocol: The protocol. This is a string that defaults to 'http'.
* <li> port: Port of remote server. This defaults to 80.
* </ul>
* </ul>
*
* For example, the <i>options</i> parameter could be set to
* <code>
* {"headers":{"Content-Type":"application/x-www-form-urlencoded"}, "method":"POST", "url":"..."}
* </code>
*
* In addition, there is a <i>command</i> input that is a string that is appended
* as a path to the URL constructed from the <i>options</i> input. This defaults
* to the empty string.
*
* The <i>arguments</i> input an object with fields that are converted to a query
* string to append to the url, for example '?arg=value'. If the value contains
* characters that are not allowed in a URL, such as spaces, they will encoded
* according to the ASCII standard, see http://www.w3schools.com/tags/ref_urlencode.asp .
*
* A <i>trigger</i> input triggers invocation of the current command. Any value provided
* on the trigger input is ignored.
*
* The output response will be a string if the MIME type of the accessed page
* begins with "text". If the MIME type begins with anything else, then the
* binary data will be produced. It is up to the host implementation to ensure
* that the data is given in some form that is usable by downstream accessors
* or actors.
*
* The parameter 'timeout' specifies how long this accessor will wait for response.
* If it does not receive the response by the specified time, then it will issue
* a null response output and an error event (calling the error() function of the host).
*
* If the parameter 'outputCompleteResponseOnly' is true (the default), then this
* accessor will produce a 'response' output only upon receiving a complete response.
* If it is false, then multiple outputs may result from a single input or trigger.
*
* @accessor net/REST
* @author Edward A. Lee (eal@eecs.berkeley.edu), contributor: Christopher Brooks
* @input {JSON} options The url for the command or an object specifying options.
* @input {string} command The command.
* @input {JSON} arguments Arguments to the command.
* @input body The request body, if any. This supports at least strings and image data.
* @input trigger An input to trigger the command.
* @output {string} response The server's response.
* @output {string} status The status code and message of the response.
* @output headers The headers sent with the response.
* @parameter {int} timeout The amount of time (in milliseconds) to wait for a response
* before triggering a null response and an error. This defaults to 5000.
* @parameter {boolean} outputCompleteResponseOnly If true (the default), the produce a
* 'response' output only upon receiving the entire response.
* @version $$Id$$
*/
// Stop extra messages from jslint and jshint. Note that there should
// be no space between the / and the * and global. See
// https://chess.eecs.berkeley.edu/ptexternal/wiki/Main/JSHint */
/*globals addInputHandler, error, exports, get, input, output, parameter, require, send */
/*jshint globalstrict: true*/
'use strict';
var httpClient = require('@accessors-modules/http-client');
var querystring = require('querystring');
/** Define inputs and outputs. */
exports.setup = function () {
this.input('options', {
'type': 'JSON', // Note that string literals are valid JSON.
'value': ''
});
this.input('command', {
'type': 'string',
'value': ''
});
this.input('arguments', {
'type': 'JSON',
'value': ''
});
this.input('trigger');
this.input('body');
this.output('response');
this.output('status', {
'type': 'string'
});
this.output('headers');
this.parameter('timeout', {
'value': 5000,
'type': 'int'
});
this.parameter('outputCompleteResponseOnly', {
'value': true,
'type': 'boolean'
});
};
/** Build the path from the command and arguments.
* This default implementation returns 'command?args', where
* args is an encoding of the arguments input for embedding in a URL.
* For example, if the arguments input is the object
* ```{ foo: 'bar', baz: ['qux', 'quux'], corge: '' }```
* then the returned string will be
* ```command?foo=bar&baz=qux&baz=quux&corge=```
* Derived accessors may override this function to customize
* the interaction. The returned string should not include a leading '/'.
* That will be added automatically.
*/
exports.encodePath = function () {
// Remove any leading slash that might be present.
var re = new RegExp('^\/');
var command = this.get('command').replace(re, '');
// Encode any characters that are not allowed in a URL.
// The test for this is:
// $PTII/bin/ptinvoke ptolemy.moml.MoMLSimpleApplication $PTII/ptolemy/actor/lib/jjs/modules/httpClient/test/auto/RESTPostDataTypes.xml
var encodedArgs;
var argumentsValue = this.get('arguments');
// If the arguments are undefined or empty, then we are done.
if (typeof argumentsValue === 'undefined' || argumentsValue === '') {
return command;
} else {
encodedArgs = querystring.stringify(argumentsValue);
return command + '?' + encodedArgs;
}
};
/** Filter the response. This base class just returns the argument
* unmodified, but derived classes can override this to extract
* a portion of the response, for example. Note that the response
* argument can be null, indicating that there was no response
* (e.g., a timeout or error occurred).
* @param response The response, or null if there is none.
*/
exports.filterResponse = function (response) {
return response;
};
// Keep track of pending HTTP request so it can be stopped if the
// model stops executing.
var request;
/** Stop a request in progress. A derived class may connect to
* a server that doesn't end the connection after sending this
* accessor the complete response. To avoid unecessary timeouts,
* the derived class may use this function. For example:
*
* exports.handleResponse = function(message){
* exports.ssuper.handleResponse.call(this, message);
* exports.ssuper.stopPendingRequest.call(this);
* };
*
*/
exports.stopPendingRequest = function () {
if (request) {
request.stop();
request = null;
}
};
/** Issue the command based on the current value of the inputs.
* This constructs a path using encodePath and combines it with the
* url input to construct the full command.
* @param callback The callback function that will be called with the
* response as an argument (an instance of IncomingMessage, defined in
* the httpClient module).
*/
exports.issueCommand = function (callback) {
var encodedPath = this.exports.encodePath.call(this);
var options = this.get('options');
var body = this.get('body');
var command = options;
if (typeof options === 'string') {
// In order to be able to include the outputCompleteResponseOnly
// option, we have to switch styles here.
command = {};
if (encodedPath) {
command.url = options + '/' + encodedPath;
} else {
command.url = options;
}
} else {
// Don't use command = options, because otherwise if we invoke
// this accessor multiple times, then options.url will be
// appended to each time. Instead, do a deep clone.
command = JSON.parse(JSON.stringify(options));
var path = "";
if (encodedPath) {
path = '/' + encodedPath;
}
if (typeof options.url === 'string') {
command.url = options.url + path;
} else {
command.url.path = path;
}
}
// NOTE: This will only be used as a connect timeout.
// Implement the request timeout locally using setTimeout().
// command.timeout = this.getParameter('timeout');
if (this.getParameter('outputCompleteResponseOnly') === false) {
command.outputCompleteResponseOnly = false;
}
if (typeof body !== 'undefined') {
command.body = body;
}
// console.log("REST.js issueCommand(): request to: " + JSON.stringify(command));
// var util = require('util');
// console.log(util.inspect(command));
request = httpClient.request(command, callback);
request.on('error', this.exports.handleError.bind(this));
var timeout = this.getParameter('timeout');
setTimeout(function() {
if (request) {
// No response has occurred.
error('The timeout period of ' + timeout
+ 'ms has been exceeded.');
request.stop();
request = null;
}
}, timeout);
request.end();
};
/** Handle an error.
* @param message The error message.
*/
exports.handleError = function(message) {
if (!message) {
message = 'Request failed. No further information.';
}
if (request) {
request.stop();
request = null;
}
error(message);
}
/** Handle the response from the RESTful service. The argument
* is expected to be be an instance of IncomingMessage, defined
* in the httpClient module. This base class extracts the body
* field of the message, if there is one, and produces that on
* the 'response' output, and otherwise just produces the message
* on the output. If the argument is null or undefined, then do
* nothing.
* @param message An incoming message.
*/
exports.handleResponse = function (message) {
if (request === null) {
// The request has already timed out. Ignore.
return;
}
// request = null; // NO! Response may be part of a multi-body response.
// Assume that if the response is null, an error will be signaled.
if (message !== null && typeof message !== 'undefined') {
// Handle redirects by creating a new command and making a new
// request. This is similar to issueCommand().
// The encodedPath is already in the URL, so we dont need to append it here.
if (message.statusCode && message.statusCode >= 300 && message.statusCode <= 308 && message.statusCode != 306) {
var body = this.get('body');
var options = this.get('options');
var command = options;
if (typeof options === 'string') {
// In order to be able to include the outputCompleteResponseOnly
// option, we have to switch styles here.
command = {};
command.url = message.headers.location;
} else {
// Don't use command = options, because otherwise if we invoke
// this accessor multiple times, then options.url will be
// appended to each time. Instead, do a deep clone.
command = JSON.parse(JSON.stringify(options));
command.url = message.headers.location;
}
command.timeout = this.getParameter('timeout');
if (this.getParameter('outputCompleteResponseOnly') === false) {
command.outputCompleteResponseOnly = false;
}
if (typeof body !== 'undefined') {
command.body = body;
}
// Make another request.
request = httpClient.request(
command,
this.exports.handleResponse.bind(this));
request.on('error', this.exports.handleError.bind(this));
var timeout = this.getParameter('timeout');
setTimeout(function() {
if (request) {
// No response has occurred.
error('The timeout period of ' + timeout
+ 'ms has been exceeded.');
}
request = null;
}, timeout);
request.end();
} else {
if (message.body) {
this.send('response', this.exports.filterResponse.call(this, message.body));
} else {
this.send('response', this.exports.filterResponse.call(this, message));
}
if (message.statusCode) {
this.send('status', message.statusCode + ': ' + message.statusMessage);
}
if (message.headers) {
this.send('headers', message.headers);
}
}
}
};
/** Register the input handler. */
exports.initialize = function () {
// Upon receiving a trigger input, issue a command.
this.addInputHandler('trigger',
this.exports.issueCommand.bind(this),
this.exports.handleResponse.bind(this));
};
/** Upon wrapup, stop handling new inputs. */
exports.wrapup = function () {
// In case there is streaming data coming in, stop it.
if (request) {
request.stop();
request = null;
}
};