diff --git a/.gitignore b/.gitignore index 0619f62e..321500b2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ node_modules lib/ /.idea *.tgz +/.vscode diff --git a/src/Agent.js b/src/Agent.js index d5af047b..468a41d8 100644 --- a/src/Agent.js +++ b/src/Agent.js @@ -26,24 +26,24 @@ export default class Agent { this.prefix = prefix(baseUrl); } - get(uri, auth, query, context) { - return this.request({ uri, auth, method: 'get', query, context }); + get(uri, auth, query, context, headers) { + return this.request({ uri, auth, method: 'get', query, context, headers }); } - head(uri, auth, query, context) { - return this.request({ uri, auth, method: 'head', query, context }); + head(uri, auth, query, context, headers) { + return this.request({ uri, auth, method: 'head', query, context, headers }); } - post(uri, data, auth, context) { - return this.request({ uri, data, auth, method: 'post', context }); + post(uri, data, auth, context, headers) { + return this.request({ uri, data, auth, method: 'post', context, headers }); } - put(uri, data, auth, context) { - return this.request({ uri, data, auth, method: 'put', context }); + put(uri, data, auth, context, headers) { + return this.request({ uri, data, auth, method: 'put', context, headers }); } - delete(uri, data, auth, context) { - return this.request({ uri, data, auth, method: 'delete', context }); + delete(uri, data, auth, context, headers) { + return this.request({ uri, data, auth, method: 'delete', context, headers }); } @@ -56,7 +56,8 @@ export default class Agent { * @param {String} query Query parameters * @param {Object} form Form fields * @param {Object} files array of file names and file content - * @parma {Object} context the invocation context, describing the tool and project. + * @param {Object} context the invocation context, describing the tool and project. + * @param {Object} headers request headers to set * @return {Promise} A promise. fulfilled with {body, statusCode}, rejected with { statusCode, errorDescription, error, body } */ request({ @@ -68,10 +69,11 @@ export default class Agent { form = undefined, files = undefined, context = undefined, - raw = false + raw = false, + headers = {} }) { const requestFiles = this._sanitizeFiles(files); - return this._request({ uri, method, data, auth, query, form, context, files: requestFiles, raw }); + return this._request({ uri, method, data, auth, query, form, context, files: requestFiles, raw, headers }); } /** @@ -84,10 +86,11 @@ export default class Agent { * @param {Object} form Form fields * @param {Object} files array of file names and file content * @param {Object} context the invocation context + * @param {Object} headers Request headers to set * @return {Promise} A promise. fulfilled with {body, statusCode}, rejected with { statusCode, errorDescription, error, body } */ - _request({ uri, method, data, auth, query, form, files, context, raw }) { - const req = this._buildRequest({ uri, method, data, auth, query, form, context, files }); + _request({ uri, method, data, auth, query, form, files, context, raw, headers }) { + const req = this._buildRequest({ uri, method, data, auth, query, form, context, files, headers }); if (raw){ return req; @@ -137,7 +140,7 @@ export default class Agent { }); } - _buildRequest({ uri, method, data, auth, query, form, files, context, makerequest = request }) { + _buildRequest({ uri, method, data, auth, query, form, files, context, headers={}, makerequest = request }) { const req = makerequest(method, uri); if (this.prefix) { req.use(this.prefix); @@ -146,6 +149,11 @@ export default class Agent { if (context) { this._applyContext(req, context); } + if (Object.keys(headers).length) { + for (const key of Object.keys(headers)) { + req.set(key, headers[key]); + } + } if (query) { req.query(query); } diff --git a/src/Particle.js b/src/Particle.js index f4ec624e..e699bc53 100644 --- a/src/Particle.js +++ b/src/Particle.js @@ -1500,6 +1500,102 @@ class Particle { return this.get(`/v1/networks/${networkId}/devices`, auth, query, context); } + /** + * Get product configuration + * @param {Object} options Options for this API call + * @param {String} options.product Team for this product ID or slug + * @param {String} options.auth Access Token + * @returns {Promise} A promise + */ + getProductConfiguration({ auth, product, context }){ + return this.get(`/v1/products/${product}/config`, auth, undefined, context); + } + + /** + * Get product configuration schema + * @param {Object} options Options for this API call + * @param {String} options.product Team for this product ID or slug + * @param {String} options.auth Access Token + * @returns {Promise} A promise + */ + getProductConfigurationSchema({ auth, product, context }){ + return this.get(`/v1/products/${product}/config`, auth, undefined, context, { + 'accept': 'application/schema+json' + }); + } + + /** + * Set product configuration + * @param {Object} options Options for this API call + * @param {String} options.product Team for this product ID or slug + * @param {String} options.auth Access Token + * @param {Object} opitons.config Product configuration to update + * @returns {Promise} A promise + */ + setProductConfiguration({ auth, product, context, config }){ + return this.put(`/v1/products/${product}/config`, config, auth, context); + } + + /** + * Set product configuration for a specific device within the product + * @param {Object} options Options for this API call + * @param {String} options.product Team for this product ID or slug + * @param {String} options.auth Access Token + * @param {Object} opitons.config Product configuration to update + * @param {String} options.deviceId Device ID to access + * @returns {Promise} A promise + */ + setProductDeviceConfiguration({ auth, product, deviceId, context, config }){ + return this.put(`/v1/products/${product}/config/${deviceId}`, config, auth, context); + } + + /** + * Query location for devices within a product + * @param {Object} options Options for this API call + * @param {String} options.product Team for this product ID or slug + * @param {String} options.auth Access Token + * @param {String} options.dateRange Start and end date in ISO8601 format, separated by comma, to query + * @param {String} options.rectBl Bottom left of the rectangular bounding box to query. Latitude and longitude separated by comma + * @param {String} options.rectTr Top right of the rectangular bounding box to query. Latitude and longitude separated by comma + * @param {String} options.deviceId Device ID prefix to include in the query + * @param {String} options.deviceName Device name prefix to include in the query + * @param {String} options.groups Array of group names to include in the query + * @param {String} options.page Page of results to display. Defaults to 1 + * @param {String} options.perPage Number of results per page. Defaults to 20. Maximum of 100 + * @returns {Promise} A promise + */ + getProductLocations({ auth, product, context, dateRange, rectBl, rectTr, deviceId, deviceName, groups, page, perPage }){ + return this.get(`/v1/products/${product}/locations`, auth, { + date_range: dateRange, + rect_bl: rectBl, + rect_tr: rectTr, + device_id: deviceId, + device_name: deviceName, + groups, + page, + per_page: perPage + }, context); + } + + /** + * Query location for one device within a product + * @param {Object} options Options for this API call + * @param {String} options.product Team for this product ID or slug + * @param {String} options.auth Access Token + * @param {String} options.dateRange Start and end date in ISO8601 format, separated by comma, to query + * @param {String} options.rectBl Bottom left of the rectangular bounding box to query. Latitude and longitude separated by comma + * @param {String} options.rectTr Top right of the rectangular bounding box to query. Latitude and longitude separated by comma + * @param {String} options.deviceId Device ID to query + * @returns {Promise} A promise + */ + getProductDeviceLocations({ auth, product, context, dateRange, rectBl, rectTr, deviceId }){ + return this.get(`/v1/products/${product}/locations/${deviceId}`, auth, { + date_range: dateRange, + rect_bl: rectBl, + rect_tr: rectTr + }, context); + } + /** * API URI to access a device * @param {Object} options Options for this API call @@ -1512,29 +1608,29 @@ class Particle { return product ? `/v1/products/${product}/devices/${deviceId}` : `/v1/devices/${deviceId}`; } - get(uri, auth, query, context){ + get(uri, auth, query, context, headers){ context = this._buildContext(context); - return this.agent.get(uri, auth, query, context); + return this.agent.get(uri, auth, query, context, headers); } - head(uri, auth, query, context){ + head(uri, auth, query, context, headers){ context = this._buildContext(context); - return this.agent.head(uri, auth, query, context); + return this.agent.head(uri, auth, query, context, headers); } - post(uri, data, auth, context){ + post(uri, data, auth, context, headers){ context = this._buildContext(context); - return this.agent.post(uri, data, auth, context); + return this.agent.post(uri, data, auth, context, headers); } - put(uri, data, auth, context){ + put(uri, data, auth, context, headers){ context = this._buildContext(context); - return this.agent.put(uri, data, auth, context); + return this.agent.put(uri, data, auth, context, headers); } - delete(uri, data, auth, context){ + delete(uri, data, auth, context, headers){ context = this._buildContext(context); - return this.agent.delete(uri, data, auth, context); + return this.agent.delete(uri, data, auth, context, headers); } request(args){ diff --git a/test/Agent.spec.js b/test/Agent.spec.js index 48439b7a..af3a6e6e 100644 --- a/test/Agent.spec.js +++ b/test/Agent.spec.js @@ -27,7 +27,7 @@ describe('Agent', () => { }); describe('resource operations', () => { - let context; + let context, headers; beforeEach(() => { context = { blah: {} }; @@ -38,7 +38,7 @@ describe('Agent', () => { sut.request = sinon.stub(); sut.request.returns('123'); expect(sut.get('abcd', 'auth', 'query', context)).to.be.equal('123'); - expect(sut.request).to.be.calledWith({ auth: 'auth', method: 'get', query: 'query', uri: 'abcd', context }); + expect(sut.request).to.be.calledWith({ auth: 'auth', method: 'get', query: 'query', uri: 'abcd', context, headers }); }); it('can head a resource', () => { @@ -46,7 +46,7 @@ describe('Agent', () => { sut.request = sinon.stub(); sut.request.returns('123'); expect(sut.head('abcd', 'auth', 'query', context)).to.be.equal('123'); - expect(sut.request).to.be.calledWith({ auth: 'auth', method: 'head', uri: 'abcd', query: 'query', context }); + expect(sut.request).to.be.calledWith({ auth: 'auth', method: 'head', uri: 'abcd', query: 'query', context, headers }); }); it('can post a resource', () => { @@ -54,7 +54,7 @@ describe('Agent', () => { sut.request = sinon.stub(); sut.request.returns('123'); expect(sut.post('abcd', 'data', 'auth', context)).to.be.equal('123'); - expect(sut.request).to.be.calledWith({ auth: 'auth', method: 'post', data: 'data', uri: 'abcd', context }); + expect(sut.request).to.be.calledWith({ auth: 'auth', method: 'post', data: 'data', uri: 'abcd', context, headers }); }); it('can put a resource', () => { @@ -62,7 +62,7 @@ describe('Agent', () => { sut.request = sinon.stub(); sut.request.returns('123'); expect(sut.put('abcd', 'data', 'auth', context)).to.be.equal('123'); - expect(sut.request).to.be.calledWith({ auth: 'auth', method: 'put', data:'data', uri: 'abcd', context }); + expect(sut.request).to.be.calledWith({ auth: 'auth', method: 'put', data:'data', uri: 'abcd', context, headers }); }); it('can delete a resource', () => { @@ -70,7 +70,7 @@ describe('Agent', () => { sut.request = sinon.stub(); sut.request.returns('123'); expect(sut.delete('abcd', 'data', 'auth', context)).to.be.equal('123'); - expect(sut.request).to.be.calledWith({ auth: 'auth', method: 'delete', data:'data', uri: 'abcd', context }); + expect(sut.request).to.be.calledWith({ auth: 'auth', method: 'delete', data:'data', uri: 'abcd', context, headers }); }); }); @@ -252,6 +252,17 @@ describe('Agent', () => { expect(extractFilename(req._formData, 'file2', 3)).to.eql('dir/file2path.cpp'); }); + it('should set headers', () => { + const sut = new Agent(); + const headers = { + 'foo': 'bar', + 'baz': 1 + }; + const req = sut._buildRequest({ uri: 'uri', method: 'get', headers }); + expect(req.header.foo).to.eql(headers.foo); + expect(req.header.baz).to.eql(headers.baz); + }); + if (!inBrowser()){ it('should handle Windows nested dirs', () => { const sut = new Agent(); @@ -299,6 +310,7 @@ describe('Agent', () => { expect(sut._request).calledOnce.calledWith({ uri: 'abc', auth: undefined, + headers: {}, method: 'post', data: '123', query: 'all', @@ -320,6 +332,7 @@ describe('Agent', () => { uri: 'abc', method:'post', auth: undefined, + headers: {}, data: undefined, files: undefined, form: undefined, @@ -345,6 +358,7 @@ describe('Agent', () => { auth:'auth', query: 'query', form: 'form', + headers: undefined, files: 'files', context }; diff --git a/test/FakeAgent.js b/test/FakeAgent.js index 652a4e49..40759bf5 100644 --- a/test/FakeAgent.js +++ b/test/FakeAgent.js @@ -1,22 +1,22 @@ export default class FakeAgent { - get(uri, auth, query = undefined, context) { - return this.request({ method: 'get', uri, auth, query, context }); + get(uri, auth, query = undefined, context, headers) { + return this.request({ method: 'get', uri, auth, query, context, headers }); } - head(uri, auth, query, context) { - return this.request({ method: 'head', uri, auth, query, context }); + head(uri, auth, query, context, headers) { + return this.request({ method: 'head', uri, auth, query, context, headers }); } - post(uri, data, auth, context) { - return this.request({ method: 'post', uri, data, auth, context }); + post(uri, data, auth, context, headers) { + return this.request({ method: 'post', uri, data, auth, context, headers }); } - put(uri, data, auth, context) { - return this.request({ method: 'put', uri, data, auth, context }); + put(uri, data, auth, context, headers) { + return this.request({ method: 'put', uri, data, auth, context, headers }); } - delete(uri, data, auth, context) { - return this.request({ method: 'delete', uri, data, auth, context }); + delete(uri, data, auth, context, headers) { + return this.request({ method: 'delete', uri, data, auth, context, headers }); } request(opts) { diff --git a/test/Particle.spec.js b/test/Particle.spec.js index 124f3de9..e47b0b7b 100644 --- a/test/Particle.spec.js +++ b/test/Particle.spec.js @@ -84,7 +84,10 @@ const props = { otp: '123456', mfaToken: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', networkId: '65535', - groups: ['foo', 'bar'] + groups: ['foo', 'bar'], + dateRange: '2020-05-15T18:29:45.000Z,2020-05-19T18:29:45.000Z', + rectBl: '56.185412,-4.049868', + rectTr: '56.571537,-5.385920' }; const product = 'ze-product-v1'; @@ -172,12 +175,10 @@ describe('ParticleAPI', () => { describe('.enableMfa', () => { it('sends request to begin mfa enrollment', () => { return api.enableMfa(props).then((results) => { - results.should.eql({ + results.should.match({ method: 'get', uri: '/v1/user/mfa-enable', - auth: props.auth, - query: undefined, - context: {} + auth: props.auth }); }); }); @@ -185,15 +186,14 @@ describe('ParticleAPI', () => { describe('.confirmMfa', () => { it('sends request to confirm mfa enrollment', () => { return api.confirmMfa(props).then((results) => { - results.should.eql({ + results.should.match({ method: 'post', uri: '/v1/user/mfa-enable', auth: props.auth, data: { otp: props.otp, mfa_token: props.mfaToken - }, - context: {} + } }); }); }); @@ -201,14 +201,13 @@ describe('ParticleAPI', () => { describe('.disableMfa', () => { it('sends request to disable mfa', () => { return api.disableMfa(props).then((results) => { - results.should.eql({ + results.should.match({ method: 'put', uri: '/v1/user/mfa-disable', auth: props.auth, data: { current_password: props.password, - }, - context: {}, + } }); }); }); @@ -230,11 +229,10 @@ describe('ParticleAPI', () => { describe('.createUser', () => { it('sends credentials', () => { return api.createUser(props).then(( results ) => { - results.should.eql({ + results.should.match({ method: 'post', uri: '/v1/users', auth: undefined, - context: {}, data: { username: props.username, password: props.password, @@ -1186,7 +1184,8 @@ describe('ParticleAPI', () => { context: {}, data: { account_info: { first_name: 'John', last_name: 'Scully' } - } + }, + headers: undefined }); }); }); @@ -1195,11 +1194,10 @@ describe('ParticleAPI', () => { it('generates request', () => { return api.changeUsername({ auth: 'X', currentPassword: 'blabla', username: 'john@skul.ly' }) .then((results) => { - results.should.eql({ + results.should.match({ method: 'put', uri: '/v1/user', auth: 'X', - context: {}, data: { current_password: 'blabla', username: 'john@skul.ly' @@ -1212,11 +1210,10 @@ describe('ParticleAPI', () => { it('generates request', () => { return api.changeUserPassword({ auth: 'X', currentPassword: 'blabla', password: 'blabla2' }) .then((results) => { - results.should.eql({ + results.should.match({ method: 'put', uri: '/v1/user', auth: 'X', - context: {}, data: { current_password: 'blabla', password: 'blabla2' @@ -1227,11 +1224,10 @@ describe('ParticleAPI', () => { it('allows invalidating tokens', () => { return api.changeUserPassword({ auth: 'X', currentPassword: 'blabla', password: 'blabla2', invalidateTokens: true }) .then((results) => { - results.should.eql({ + results.should.match({ method: 'put', uri: '/v1/user', auth: 'X', - context: {}, data: { current_password: 'blabla', password: 'blabla2', @@ -2038,6 +2034,106 @@ describe('ParticleAPI', () => { }); }); + describe('.getProductConfiguration', () => { + it('generates request', () => { + return api.getProductConfiguration(propsWithProduct).then((results) => { + results.should.match({ + method: 'get', + uri: `/v1/products/${product}/config`, + auth: props.auth + }); + }); + }); + }); + + describe('.getProductConfigurationSchema', () => { + it('generates request', () => { + return api.getProductConfigurationSchema(propsWithProduct).then((results) => { + results.should.match({ + method: 'get', + uri: `/v1/products/${product}/config`, + auth: props.auth, + headers: { 'accept': 'application/schema+json' } + }); + }); + }); + }); + + describe('.setProductConfiguration', () => { + it('generates request', () => { + const p = Object.assign({ config: { + foo: 'bar' + } }, propsWithProduct); + return api.setProductConfiguration(p).then((results) => { + results.should.match({ + method: 'put', + uri: `/v1/products/${product}/config`, + auth: props.auth, + data: { + foo: 'bar' + } + }); + }); + }); + }); + + describe('.setProductDeviceConfiguration', () => { + it('generates request', () => { + const p = Object.assign({ config: { + foo: 'bar' + } }, propsWithProduct); + return api.setProductDeviceConfiguration(p).then((results) => { + results.should.match({ + method: 'put', + uri: `/v1/products/${product}/config/${props.deviceId}`, + auth: props.auth, + data: { + foo: 'bar' + } + }); + }); + }); + }); + + describe('.getProductLocations', () => { + it('generates request', () => { + return api.getProductLocations(propsWithProduct).then((results) => { + results.should.match({ + method: 'get', + uri: `/v1/products/${product}/locations`, + auth: props.auth, + query: { + date_range: props.dateRange, + rect_bl: props.rectBl, + rect_tr: props.rectTr, + device_id: props.deviceId, + device_name: props.deviceName, + groups: props.groups, + page: props.page, + per_page: props.perPage + } + }); + }); + }); + }); + + describe('.getProductDeviceLocations', () => { + it('generates request', () => { + return api.getProductDeviceLocations(propsWithProduct).then((results) => { + results.should.match({ + method: 'get', + uri: `/v1/products/${product}/locations/${props.deviceId}`, + auth: props.auth, + query: { + date_range: props.dateRange, + rect_bl: props.rectBl, + rect_tr: props.rectTr + } + }); + }); + }); + }); + describe('.deleteUser', () => { it('sends request to delete the current user', () => { return api.deleteUser(props).then(result => {