Skip to content

Commit

Permalink
(feature:device): custom user agent (#572)
Browse files Browse the repository at this point in the history
* custom user agent

* make sure not overwriting pr

* add amqp to interface

* remove trailing whitespace

* fix asterisks

* remove trailing whitespace
  • Loading branch information
Yoseph Maguire authored Jul 16, 2019
1 parent 232270b commit 78e9c60
Show file tree
Hide file tree
Showing 15 changed files with 288 additions and 33 deletions.
8 changes: 7 additions & 1 deletion device/core/devdoc/utils_requirement.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ getUserAgentString returns a user agent string that can be sent to the service.

**SRS_NODE_DEVICE_UTILS_18_001: [** `getUserAgentString` shall call `getAgentPlatformString` to get the platform string. **]**

**SRS_NODE_DEVICE_UTILS_18_002: [** `getUserAgentString` shall call its `callback` with a string in the form 'azure-iot-device/<packageJson.version>(<platformString>)'. **]**
**SRS_NODE_DEVICE_UTILS_18_002: [** `getUserAgentString` shall call its `callback` with a string in the form 'azure-iot-device/<packageJson.version>(<platformString>)<productInfo>'. **]**

**SRS_NODE_DEVICE_UTILS_41_001: [** `getUserAgentString` shall not add any custom product Info if a `falsy` value is passed in as the first arg. **]**

**SRS_NODE_DEVICE_UTILS_41_002: [** `getUserAgentString` shall accept productInfo as a `string` so that the callback is called with a string in the form 'azure-iot-device/<packageJson.version>(<platformString>)<productInfo>'. **]**

**SRS_NODE_DEVICE_UTILS_41_003: [** `getUserAgentString` shall throw if the first arg is not `falsy`, or of type `string` or `function`. **]**
16 changes: 11 additions & 5 deletions device/core/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,17 +118,23 @@ export interface DeviceClientOptions extends X509 {
ca?: string;

/**
* Optional object with options specific to the Mqtt transport
* Custom user defined information to be appended to existing User Agent information. The User Agent Identification information
* is used predominantly by Microsoft internally for identifying metadata related to Device Client usage for Azure IoT.
*/
mqtt?: MqttTransportOptions;
productInfo?: string;

/**
* Optional object with options specific to the Mqtt transport
* Optional object with options specific to the MQTT transport
*/
http?: HttpTransportOptions;
mqtt?: MqttTransportOptions;

/**
* Optional object with options specific to the Amqp transport
* Optional object with options specific to the HTTP transport
*/
http?: HttpTransportOptions;

/**
* Optional object with options specific to the AMQP transport
*/
amqp?: AmqpTransportOptions;
}
4 changes: 3 additions & 1 deletion device/core/src/module_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,9 @@ export class ModuleClient extends InternalClient {
setOptions(options: DeviceClientOptions, done?: Callback<results.TransportConfigured>): Promise<results.TransportConfigured> | void {
return callbackToPromise((_callback) => {
/*Codes_SRS_NODE_MODULE_CLIENT_16_098: [The `setOptions` method shall call the `setOptions` method with the `options` argument on the `MethodClient` object of the `ModuleClient`.]*/
this._methodClient.setOptions(options);
if (this._methodClient) {
this._methodClient.setOptions(options);
}
/*Codes_SRS_NODE_MODULE_CLIENT_16_042: [The `setOptions` method shall throw a `ReferenceError` if the options object is falsy.]*/
/*Codes_SRS_NODE_MODULE_CLIENT_16_043: [The `_callback` callback shall be invoked with no parameters when it has successfully finished setting the client and/or transport options.]*/
/*Codes_SRS_NODE_MODULE_CLIENT_16_044: [The `_callback` callback shall be invoked with a standard javascript `Error` object and no result object if the client could not be configured as requested.]*/
Expand Down
33 changes: 30 additions & 3 deletions device/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,39 @@ const packageJson = require('../package.json');

export function getUserAgentString(done: NoErrorCallback<string>): void;
export function getUserAgentString(): Promise<string>;
export function getUserAgentString(done?: NoErrorCallback<string>): Promise<string> | void {
export function getUserAgentString(productInfo: string, done: NoErrorCallback<string>): void;
export function getUserAgentString(productInfo: string): Promise<string>;
export function getUserAgentString(productInfoOrDone?: string | NoErrorCallback<string>, doneOrNone?: NoErrorCallback<string>): Promise<string> | void {
let productInfo: string;
let done: NoErrorCallback<string>;

/*Codes_SRS_NODE_DEVICE_UTILS_41_001: [`getUserAgentString` shall not add any custom product Info if a `falsy` value is passed in as the first arg.]*/
if (!productInfoOrDone) {
productInfo = '';
done = doneOrNone;
} else {
switch (typeof(productInfoOrDone)) {
/*Codes_SRS_NODE_DEVICE_UTILS_41_002: [`getUserAgentString` shall accept productInfo as a `string` so that the callback is called with a string in the form 'azure-iot-device/<packageJson.version>(<platformString>)<productInfo>'.]*/
case 'string': {
productInfo = <string> productInfoOrDone;
done = doneOrNone;
break;
}
case 'function': {
productInfo = '';
done = <NoErrorCallback<string>> productInfoOrDone;
break;
}
/*Codes_SRS_NODE_DEVICE_UTILS_41_003: [`getUserAgentString` shall throw if the first arg is not `falsy`, or of type `string` or `function`.]*/
default:
throw new TypeError('Error: productInfo must be of type \'string\'');
}
}
return noErrorCallbackToPromise((_callback) => {
/*Codes_SRS_NODE_DEVICE_UTILS_18_001: [`getUserAgentString` shall call `getAgentPlatformString` to get the platform string.]*/
getAgentPlatformString((platformString) => {
/*Codes_SRS_NODE_DEVICE_UTILS_18_002: [`getUserAgentString` shall call its `callback` with a string in the form 'azure-iot-device/<packageJson.version>(<platformString>)'.]*/
_callback(packageJson.name + '/' + packageJson.version + ' (' + platformString + ')');
/*Codes_SRS_NODE_DEVICE_UTILS_18_002: [`getUserAgentString` shall call its `callback` with a string in the form 'azure-iot-device/<packageJson.version>(<platformString>)<productInfo>'.]*/
_callback(packageJson.name + '/' + packageJson.version + ' (' + platformString + ')' + productInfo);
});
}, done);
}
20 changes: 19 additions & 1 deletion device/core/test/_internal_client_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ var ModuleClient = require('../lib/module_client').ModuleClient;

describe('#setTransportOptions', function () {
/*Tests_SRS_NODE_INTERNAL_CLIENT_16_021: [The ‘setTransportOptions’ method shall call the ‘setOptions’ method on the transport object.]*/
/*Tests_SRS_NODE_INTERNAL_CLIENT_16_022: [The ‘done’ callback shall be invoked with a null error object and a ‘TransportConfigured’ object nce the transport has been configured.]*/
/*Tests_SRS_NODE_INTERNAL_CLIENT_16_022: [The ‘done’ callback shall be invoked with a null error object and a ‘TransportConfigured’ object once the transport has been configured.]*/
it('calls the setOptions method on the transport object and gives it the options parameter', function (done) {
var testOptions = { foo: 42 };
var dummyTransport = new FakeTransport();
Expand Down Expand Up @@ -202,6 +202,24 @@ var ModuleClient = require('../lib/module_client').ModuleClient;
testCallback();
});
});

it('productInfo properly sets string', function (done) {
var testOptions = { productInfo: 'test'};
var dummyTransport = new FakeTransport();
sinon.stub(dummyTransport, 'setOptions').callsFake(function (options, callback) {
assert.strictEqual(options.productInfo, testOptions.productInfo);
callback(null, new results.TransportConfigured());
});

var client = new ClientCtor(dummyTransport);
client.setOptions(testOptions, function (err, results) {
if (err) {
done(err);
} else {
done();
}
});
});
});

describe('#open', function () {
Expand Down
41 changes: 35 additions & 6 deletions device/core/test/_utils_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ var packageJson = require('../package.json');

describe('getUserAgentString', function() {
var fakePlatformString = 'fakePlatformString';

var fakeProductInfoString = 'fakeProductInfoString';
before(function() {
sinon.stub(core, 'getAgentPlatformString').callsArgWith(0, fakePlatformString);
});
Expand All @@ -20,16 +20,45 @@ describe('getUserAgentString', function() {
core.getAgentPlatformString.restore();
});

/*Codes_SRS_NODE_DEVICE_UTILS_18_001: [`getUserAgentString` shall call `getAgentPlatformString` to get the platform string.]*/
/*Codes_SRS_NODE_DEVICE_UTILS_18_002: [`getUserAgentString` shall call its `callback` with a string in the form 'azure-iot-device/<packageJson.version>(<platformString>)'.]*/
/*Tests_SRS_NODE_DEVICE_UTILS_18_001: [`getUserAgentString` shall call `getAgentPlatformString` to get the platform string.]*/
/*Tests_SRS_NODE_DEVICE_UTILS_18_002: [`getUserAgentString` shall call its `callback` with a string in the form 'azure-iot-device/<packageJson.version>(<platformString>)<productInfo>'.]*/
it ('returns the right string', function(callback) {
getUserAgentString(function(actual) {
assert.equal(actual, 'azure-iot-device/' + packageJson.version + ' (' + fakePlatformString + ')');
getUserAgentString(function(actualAgentString) {
assert.equal(actualAgentString, 'azure-iot-device/' + packageJson.version + ' (' + fakePlatformString + ')');
callback();
});
});
});

/*Tests_SRS_NODE_DEVICE_UTILS_41_001: [`getUserAgentString` shall not add any custom product Info if a `falsy` value is passed in as the first arg.]*/
it('does not populate productInfo for falsy values', function () {
['', null, undefined].forEach(function (falsyValue) {
getUserAgentString(falsyValue, function(actualAgentString) {
assert.strictEqual(actualAgentString, 'azure-iot-device/' + packageJson.version + ' (' + fakePlatformString + ')');
});
});
});

/*Tests_SRS_NODE_DEVICE_UTILS_41_002: [`getUserAgentString` shall accept productInfo as a `string` so that the callback is called with a string in the form 'azure-iot-device/<packageJson.version>(<platformString>)<productInfo>'.]*/
it('returns the right string with productInfo', function(callback) {
getUserAgentString(fakeProductInfoString, function(actualAgentString) {
assert.strictEqual(actualAgentString, 'azure-iot-device/' + packageJson.version + ' (' + fakePlatformString + ')' + fakeProductInfoString);
callback();
});
});

/*Tests_SRS_NODE_DEVICE_UTILS_41_003: [`getUserAgentString` shall throw if the first arg is not `falsy`, or of type `string` or `function`.]*/
it('throws on wrong type for productInfo', function() {
[41, [5, 1], {test: 'test'}].forEach(function (badValue) {
assert.throws(function() {
getUserAgentString(badValue, function(actualAgentString) {
console.log(actualAgentString);
});
});
});
});


});



4 changes: 4 additions & 0 deletions device/transport/amqp/devdoc/device_amqp_requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ The `connect` method establishes a connection with the Azure IoT Hub instance.

**SRS_NODE_DEVICE_AMQP_99_084: [** The `connect` method shall set the HTTPS agent on the options object when calling the underlying connection object's connect method if it was supplied. **]**

**SRS_NODE_DEVICE_AMQP_41_001: [** The AMQP transport should use the productInfo string in the `options` object if present **]**

**SRS_NODE_DEVICE_AMQP_41_002: [** The connect method shall set the productInfo on the options object when calling the underlying connection object's connect method if it was supplied. **]**

### disconnect(done)
The `disconnect` method terminates the connection with the Azure IoT Hub instance.

Expand Down
12 changes: 10 additions & 2 deletions device/transport/amqp/src/amqp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,13 +278,20 @@ export class Amqp extends EventEmitter implements DeviceTransport {
this._d2cEndpoint = endpoint.deviceEventPath(credentials.deviceId);
this._messageEventName = 'message';
}

getUserAgentString((userAgentString) => {
/*Tests_SRS_NODE_DEVICE_AMQP_41_001: [ The AMQP transport should use the productInfo string in the `options` object if present ]*/
/*Tests_SRS_NODE_DEVICE_AMQP_41_002: [ The connect method shall set the productInfo on the options object when calling the underlying connection object's connect method if it was supplied. ]*/
const customInfo = (this._options && this._options.productInfo) ? this._options.productInfo : '';
getUserAgentString(customInfo, (userAgentString) => {
const config: AmqpBaseTransportConfig = {
uri: this._getConnectionUri(credentials),
sslOptions: credentials.x509,
userAgentString: userAgentString
};
/*Codes_SRS_NODE_DEVICE_AMQP_13_002: [ The connect method shall set the CA cert on the options object when calling the underlying connection object's connect method if it was supplied. ]*/
// if (this._options && this._options.ca) {
// config.sslOptions = config.sslOptions || {};
// config.sslOptions.ca = this._options.ca;
// }
if (this._options) {
config.sslOptions = config.sslOptions || {};
/*Codes_SRS_NODE_DEVICE_AMQP_13_002: [ The connect method shall set the CA cert on the options object when calling the underlying connection object's connect method if it was supplied. ]*/
Expand Down Expand Up @@ -688,6 +695,7 @@ export class Amqp extends EventEmitter implements DeviceTransport {
}
}


/*Codes_SRS_NODE_DEVICE_AMQP_13_001: [ The setOptions method shall save the options passed in. ]*/
this._options = options;
if (done) {
Expand Down
21 changes: 20 additions & 1 deletion device/transport/amqp/test/_amqp_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ var errors = require('azure-iot-common').errors;
var results = require('azure-iot-common').results;
var AuthenticationType = require('azure-iot-common').AuthenticationType;


describe('Amqp', function () {
var transport = null;
var receiver = null;
Expand Down Expand Up @@ -410,6 +411,18 @@ describe('Amqp', function () {
testCallback();
});
});

/*Tests_SRS_NODE_DEVICE_AMQP_41_002: [ The connect method shall set the productInfo on the options object when calling the underlying connection object's connect method if it was supplied. ]*/
it('sets productInfo if provided', function (testCallback) {
var options = { productInfo: 'test: THIS IS A TEST'};
transport.setOptions(options);
transport.connect(function (err) {
assert.isNotOk(err);
assert(fakeBaseClient.connect.called);
assert(fakeBaseClient.connect.firstCall.args[0].userAgentString.includes(options.productInfo));
testCallback();
});
});

it('sets gateway host name if provided', function (testCallback) {
fakeTokenAuthenticationProvider.getDeviceCredentials = sinon.stub().callsArgWith(0, null, configWithGatewayHostName);
Expand Down Expand Up @@ -881,10 +894,16 @@ describe('Amqp', function () {
});

/*Tests_SRS_NODE_DEVICE_AMQP_13_001: [ The setOptions method shall save the options passed in. ]*/
it('saves options', function () {
it('saves CA options', function () {
transport.setOptions({ ca: 'ca cert' });
assert.strictEqual(transport._options.ca, 'ca cert');
});

/*Tests_SRS_NODE_DEVICE_AMQP_41_001: [The AMQP transport should use the productInfo string in the `options` object if present]*/
it('saves productInfo options', function () {
transport.setOptions({ productInfo: 'customer user agent information' });
assert.strictEqual(transport._options.productInfo, 'customer user agent information');
});
});

describe('on(\'disconnect\')', function () {
Expand Down
12 changes: 9 additions & 3 deletions device/transport/http/devdoc/http_requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,17 @@ Host: <config.host>

**SRS_NODE_DEVICE_HTTP_RECEIVER_16_017: [**If `opts.drain` is true all messages in the queue should be pulled at once.**]**

**SRS_NODE_DEVICE_HTTP_RECEIVER_16_018: [**If `opts.drain` is false, only one message shall be received at a time**]**
**SRS_NODE_DEVICE_HTTP_RECEIVER_16_018: [**If `opts.drain` is false, only one message shall be received at a time **]**

**SRS_NODE_DEVICE_HTTP_RECEIVER_16_019: [**If the receiver is already running with a previous configuration, the existing receiver should be restarted with the new configuration**]**
**SRS_NODE_DEVICE_HTTP_RECEIVER_16_019: [**If the receiver is already running with a previous configuration, the existing receiver should be restarted with the new configuration **]**

**SRS_NODE_DEVICE_HTTP_RECEIVER_16_023: [**If `opts.manualPolling` is true, messages shall be received only when receive() is called**]**
**SRS_NODE_DEVICE_HTTP_RECEIVER_16_023: [**If `opts.manualPolling` is true, messages shall be received only when receive() is called **]**

**SRS_NODE_DEVICE_HTTP_41_001: [** The HTTP transport should use the productInfo string in the `options` object if present **]**

**SRS_NODE_DEVICE_HTTP_41_002: [** `productInfo` should be set in the HTTP User-Agent Header if set using `setOptions` **]**

**SRS_NODE_DEVICE_HTTP_41_003: [** `productInfo` must be set before `http._ensureAgentString` is invoked for the first time **]**

### abandon(message, done)

Expand Down
18 changes: 15 additions & 3 deletions device/transport/http/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export class Http extends EventEmitter implements DeviceTransport {
private _timeoutObj: number;
private _receiverStarted: boolean;
private _userAgentString: string;
private _productInfo: string;

/**
* @private
Expand Down Expand Up @@ -159,6 +160,7 @@ export class Http extends EventEmitter implements DeviceTransport {
done(err);
} else {
const path = endpoint.deviceEventPath(encodeUriComponentStrict(config.deviceId));
/*Codes_SRS_NODE_DEVICE_HTTP_41_002: [ `productInfo` should be set in the HTTP User-Agent Header if set using `setOptions` ]*/
let httpHeaders = {
'iothub-to': path,
'User-Agent': this._userAgentString
Expand Down Expand Up @@ -325,13 +327,22 @@ export class Http extends EventEmitter implements DeviceTransport {
});
}

/*Codes_SRS_NODE_DEVICE_HTTP_41_001: [ The HTTP transport should use the productInfo string in the `options` object if present ]*/
if (options.productInfo) {
// To enforce proper use of the productInfo option, if the setOption is called after HTTP calls have already been made (therefore _userAgentString already set) an error is thrown.
if (this._userAgentString) {
/*Codes_SRS_NODE_DEVICE_HTTP_41_003: [ `productInfo` must be set before `http._ensureAgentString` is invoked for the first time ]*/
throw Error('Ensure you call setOption for productInfo before initiating any connection to IoT Hub');
} else {
this._productInfo = options.productInfo;
}
}

/*Codes_SRS_NODE_DEVICE_HTTP_16_010: [`setOptions` should not throw if `done` has not been specified.]*/
/*Codes_SRS_NODE_DEVICE_HTTP_16_005: [If `done` has been specified the `setOptions` method shall call the `done` callback with no arguments when successful.]*/
/*Codes_SRS_NODE_DEVICE_HTTP_16_009: [If `done` has been specified the `setOptions` method shall call the `done` callback with a standard javascript `Error` object when unsuccessful.]*/
this._http.setOptions(options);

this._http.setOptions(options);

// setOptions used to exist both on Http and HttpReceiver with different options class. In order not to break backward compatibility we have
// to check what properties this options object has to figure out what to do with it.
if (options.hasOwnProperty('http') && options.http.hasOwnProperty('receivePolicy')) {
Expand Down Expand Up @@ -786,10 +797,11 @@ export class Http extends EventEmitter implements DeviceTransport {
if (this._userAgentString) {
done();
} else {
getUserAgentString((agent) => {
getUserAgentString(this._productInfo, (agent) => {
this._userAgentString = agent;
done();
});
}
}
}

Loading

0 comments on commit 78e9c60

Please sign in to comment.