hmpo-model 3.2.1 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,91 +1,165 @@
1
1
  'use strict';
2
2
 
3
+ const debug = require('debug')('hmpo:model:remote');
3
4
  const LocalModel = require('./local-model');
4
- const requestLib = require('request');
5
- const _ = require('underscore');
5
+ const got = require('got');
6
6
  const kebabCase = require('lodash.kebabcase');
7
- const hmpoLogger = require('hmpo-logger');
7
+ const { URL } = require('url');
8
+ const ModelError = require('./model-error');
9
+
10
+ const DEFAULT_TIMEOUT = 60000;
8
11
 
9
12
  class RemoteModel extends LocalModel {
10
13
  constructor(attributes, options) {
11
14
  super(attributes, options);
12
-
15
+ this.got = got;
13
16
  this.options.label = this.options.label || kebabCase(this.constructor.name);
14
17
  this.setLogger();
15
18
  }
16
19
 
17
20
  setLogger() {
18
- this.logger = hmpoLogger.get(':' + this.options.label);
21
+ try {
22
+ const hmpoLogger = require('hmpo-logger');
23
+ this.logger = hmpoLogger.get(':' + this.options.label);
24
+ } catch (e) {
25
+ console.error('Error setting logger, using console instead!', e);
26
+ this.logger = { outbound: console.log, trimHtml: html => html };
27
+ }
19
28
  }
20
29
 
21
- fetch(callback) {
22
- let config = this.requestConfig({method: 'GET'});
30
+ fetch(args, callback) {
31
+ if (typeof args === 'function') {
32
+ callback = args;
33
+ args = undefined;
34
+ }
35
+ const config = this.requestConfig({method: 'GET'}, args);
23
36
  this.request(config, callback);
24
37
  }
25
38
 
26
- save(callback) {
27
- this.prepare((err, data) => {
28
-
29
- if (err) { return callback(err); }
30
-
31
- data = JSON.stringify(data);
32
-
33
- let config = this.requestConfig({
34
- method: 'POST',
35
- headers: {
36
- 'Content-Type': 'application/json',
37
- 'Content-Length': Buffer.byteLength(data)
38
- }
39
- });
40
-
41
- this.request(config, data, callback);
42
-
39
+ save(args, callback) {
40
+ if (typeof args === 'function') {
41
+ callback = args;
42
+ args = undefined;
43
+ }
44
+ this.prepare((err, json) => {
45
+ if (err) return callback(err);
46
+ const config = this.requestConfig({method: 'POST', json}, args);
47
+ this.request(config, callback);
43
48
  });
44
49
  }
45
50
 
46
- delete(callback) {
47
- let config = this.requestConfig({method: 'DELETE'});
51
+ delete(args, callback) {
52
+ if (typeof args === 'function') {
53
+ callback = args;
54
+ args = undefined;
55
+ }
56
+ const config = this.requestConfig({method: 'DELETE'}, args);
48
57
  this.request(config, callback);
49
58
  }
50
59
 
51
- requestConfig(config) {
52
- let retConfig = _.clone(config);
60
+ prepare(callback) {
61
+ debug('prepare');
62
+ callback(null, this.toJSON());
63
+ }
64
+
65
+ requestConfig(config, args) {
66
+ const retConfig = Object.assign({}, config);
53
67
 
54
- retConfig.uri = this.url();
68
+ retConfig.url = this.url(retConfig.url || retConfig.uri, args);
55
69
 
56
- let auth = this.auth();
70
+ retConfig.timeout = this.timeout(retConfig.timeout);
57
71
 
72
+ const auth = this.auth(retConfig.auth);
58
73
  if (auth) {
59
- retConfig.auth = auth;
74
+ retConfig.username = auth.username || auth.user;
75
+ retConfig.password = auth.password || auth.pass;
60
76
  }
77
+ delete retConfig.auth;
61
78
 
62
- if (this.options.headers) {
63
- retConfig.headers = _.defaults(retConfig.headers || {}, this.options.headers);
79
+ const agent = this.proxy(retConfig.proxy, retConfig.url);
80
+ if (agent) {
81
+ retConfig.agent = agent;
64
82
  }
83
+ delete retConfig.proxy;
84
+
85
+ const headers = Object.assign({}, this.options.headers, retConfig.headers);
86
+ if (Object.keys(headers).length) retConfig.headers = headers;
87
+
88
+ debug('requestConfig', retConfig);
65
89
 
66
90
  return retConfig;
67
91
  }
68
92
 
69
- request(settings, body, callback) {
70
- if (typeof body === 'function' && arguments.length === 2) {
71
- callback = body;
72
- body = undefined;
93
+ url(url = this.options.url) {
94
+ return url;
95
+ }
96
+
97
+ auth(auth = this.options.auth) {
98
+ if (typeof auth === 'string') {
99
+ const splitAuth = auth.split(':');
100
+ auth = {
101
+ username: splitAuth.shift(),
102
+ password: splitAuth.join(':')
103
+ };
73
104
  }
105
+ return auth;
106
+ }
74
107
 
75
- // This is cloned and set so that the post body is not kept around in memory
76
- // settings is a shallow object, so the requestSettings is entirely made up of copied properties
77
- let requestSettings = _.clone(settings);
78
- requestSettings.body = body;
108
+ timeout(timeout = this.options.timeout || DEFAULT_TIMEOUT) {
109
+ if (typeof timeout === 'number') {
110
+ timeout = {
111
+ lookup: timeout,
112
+ connect: timeout,
113
+ secureConnect: timeout,
114
+ socket: timeout,
115
+ send: timeout,
116
+ response: timeout
117
+ };
118
+ }
119
+ return timeout;
120
+ }
121
+
122
+ proxy(proxy = this.options.proxy, url) {
123
+ if (!proxy || !url) return;
124
+
125
+ if (typeof proxy === 'string') proxy = { proxy };
126
+
127
+ const isHttps = (new URL(url).protocol === 'https:');
79
128
 
80
- let startTime = process.hrtime();
129
+ if (isHttps) {
130
+ const { HttpsProxyAgent } = require('hpagent');
131
+ return {
132
+ https: new HttpsProxyAgent(Object.assign({
133
+ keepAlive: false,
134
+ maxSockets: 1,
135
+ maxFreeSockets: 1,
136
+ }, proxy))
137
+ };
138
+ } else {
139
+ const { HttpProxyAgent } = require('hpagent');
140
+ return {
141
+ http: new HttpProxyAgent(Object.assign({
142
+ keepAlive: false,
143
+ maxSockets: 1,
144
+ maxFreeSockets: 1,
145
+ }, proxy))
146
+ };
147
+ }
148
+ }
81
149
 
82
- let _callback = (err, data, statusCode) => {
150
+ request(settings, callback) {
151
+ this.hookSync({settings});
152
+ this.logSync({settings});
153
+ this.emit('sync', settings);
83
154
 
84
- // This uses node's "high resolution time" to determine response time.
85
- // The calculation is to translate seconds & nanoseconds into a number of format 1.234 seconds
86
- let endTime = process.hrtime(startTime);
87
- let responseTime = Number((endTime[0]*1000 + endTime[1]/1000000).toFixed(3));
155
+ let responseTime;
156
+ const startTime = process.hrtime.bigint();
157
+ const setResponseTime = () => {
158
+ const endTime = process.hrtime.bigint();
159
+ responseTime = Number((Number(endTime - startTime) / 1000000).toFixed(3));
160
+ };
88
161
 
162
+ const _callback = (err, data, statusCode) => {
89
163
  if (err) {
90
164
  this.hookFail({settings, statusCode, responseTime, err, data});
91
165
  this.logError({settings, statusCode, responseTime, err, data});
@@ -100,83 +174,74 @@ class RemoteModel extends LocalModel {
100
174
  }
101
175
  };
102
176
 
103
- requestLib(requestSettings, (err, response) => {
104
- if (err) {
105
- if (err.code === 'ETIMEDOUT') {
106
- err.message = 'Connection timed out';
107
- err.status = 504;
177
+ this.got(settings)
178
+ .then(response => {
179
+ debug('request got response', response);
180
+ setResponseTime();
181
+ this.handleResponse(response, _callback);
182
+ })
183
+ .catch(err => {
184
+ setResponseTime();
185
+ if (err.code === 'ERR_NON_2XX_3XX_RESPONSE' && err.response) {
186
+ return this.handleResponse(err.response, _callback);
108
187
  }
109
- err.status = err.status || (response && response.statusCode) || 503;
110
- return _callback(err, null, err.status);
111
- }
112
- this.handleResponse(response, _callback);
113
- });
114
-
115
- this.hookSync({settings});
116
- this.logSync({settings});
117
- this.emit('sync', settings);
188
+ err = new ModelError(err);
189
+ debug('request got error', err);
190
+ _callback(err, null, err.status);
191
+ });
118
192
  }
119
193
 
120
194
  handleResponse(response, callback) {
121
- let data = {};
195
+ debug('handleResponse', response);
196
+ let data;
122
197
  try {
123
198
  data = JSON.parse(response.body || '{}');
124
199
  } catch (err) {
125
200
  err.status = response.statusCode;
126
201
  err.body = response.body;
127
- return callback(err, null, response.statusCode);
202
+ return callback(err, null, err.status);
128
203
  }
129
204
  this.parseResponse(response.statusCode, data, callback);
130
205
  }
131
206
 
132
207
  parseResponse(statusCode, data, callback) {
133
- if (statusCode < 400) {
134
- try {
135
- data = this.parse(data);
136
- callback(null, data, statusCode);
137
- } catch (err) {
138
- callback(err, null, statusCode);
139
- }
140
- } else {
141
- callback(this.parseError(statusCode, data), data, statusCode);
208
+ debug('parseResponse', statusCode, data);
209
+
210
+ if (statusCode >= 400) {
211
+ const error = this.parseError(statusCode, data);
212
+ return callback(error, data, statusCode);
142
213
  }
143
- }
144
214
 
145
- prepare(callback) {
146
- callback(null, this.toJSON());
215
+ try {
216
+ data = this.parse(data);
217
+ } catch (err) {
218
+ return callback(err, null, statusCode);
219
+ }
220
+
221
+ callback(null, data, statusCode);
147
222
  }
148
223
 
149
224
  parse(data) {
225
+ debug('parse', data);
226
+ if (data && typeof data === 'object') {
227
+ if (Array.isArray(data)) {
228
+ this.set('data', data);
229
+ } else {
230
+ this.set(data);
231
+ }
232
+ }
150
233
  return data;
151
234
  }
152
235
 
153
236
  parseError(statusCode, data) {
154
- return _.extend({ status: statusCode }, data);
155
- }
156
-
157
- url() {
158
- return this.options.url;
159
- }
160
-
161
- auth(credentials) {
162
- if (!credentials) return;
163
-
164
- if (typeof credentials === 'string') {
165
- let auth = credentials.split(':');
166
- credentials = {
167
- user: auth.shift(),
168
- pass: auth.join(':'),
169
- sendImmediately: true
170
- };
171
- }
172
-
173
- return credentials;
237
+ debug('parseError, statusCode, data');
238
+ return Object.assign({ status: statusCode }, data);
174
239
  }
175
240
 
176
241
  logMeta(tokenData) {
177
242
  let data = {
178
243
  outVerb: tokenData.settings.method,
179
- outRequest: tokenData.settings.uri
244
+ outRequest: tokenData.settings.url
180
245
  };
181
246
 
182
247
  if (tokenData.statusCode) {
@@ -194,7 +259,7 @@ class RemoteModel extends LocalModel {
194
259
  data.outErrorBody = this.logger.trimHtml(tokenData.err.body);
195
260
  }
196
261
 
197
- _.extend(data, this.options.logging);
262
+ Object.assign(data, this.options.logging);
198
263
 
199
264
  return data;
200
265
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hmpo-model",
3
- "version": "3.2.1",
3
+ "version": "4.0.2",
4
4
  "description": "Simple model for interacting with http/rest apis.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -25,22 +25,20 @@
25
25
  },
26
26
  "homepage": "https://github.com/UKHomeOffice/passports-model",
27
27
  "dependencies": {
28
- "lodash.kebabcase": "^4.1.1",
29
- "request": "^2.88.2",
30
- "underscore": "^1.13.1"
28
+ "debug": "^4.3.3",
29
+ "got": "^11.8.3",
30
+ "hpagent": "^0.1.2",
31
+ "lodash.kebabcase": "^4.1.1"
31
32
  },
32
33
  "devDependencies": {
33
34
  "chai": "^4.3.4",
34
- "eslint": "^7.26.0",
35
+ "eslint": "^8.3.0",
35
36
  "hmpo-logger": "^4.1.3",
36
- "mocha": "^8.4.0",
37
+ "mocha": "^9.1.3",
37
38
  "nyc": "^15.1.0",
38
39
  "proxyquire": "^2.0.0",
39
- "sinon": "^10.0.0",
40
- "sinon-chai": "^3.6.0"
41
- },
42
- "peerDependencies": {
43
- "hmpo-logger": ">= 4"
40
+ "sinon": "^12.0.1",
41
+ "sinon-chai": "^3.7.0"
44
42
  },
45
43
  "nyc": {
46
44
  "all": true,
@@ -1,13 +1,11 @@
1
1
  'use strict';
2
2
 
3
- describe('Local Model', () => {
3
+ const Model = require('../../lib/local-model');
4
4
 
5
+ describe('Local Model', () => {
5
6
  let model;
6
7
 
7
8
  beforeEach(() => {
8
-
9
- let Model = require('../../lib/local-model');
10
-
11
9
  model = new Model();
12
10
  });
13
11
 
@@ -18,7 +16,7 @@ describe('Local Model', () => {
18
16
  });
19
17
 
20
18
  it('has an attributes property of type object', () => {
21
- model.attributes.should.be.a('object');
19
+ expect(model.attributes).to.be.an('object');
22
20
  });
23
21
 
24
22
  describe('constructor', () => {
@@ -54,20 +52,28 @@ describe('Local Model', () => {
54
52
  describe('set', () => {
55
53
 
56
54
  beforeEach(() => {
57
- model.attributes = {
58
- name: 'Test name'
59
- };
55
+ model = new Model({ name: 'Test name' });
60
56
  });
61
57
 
62
58
  it('adds a key to the model attributes if the key is a string', () => {
63
- model.set('age', 20).attributes.should.eql({
59
+ model.set('age', 20);
60
+ expect(model.attributes).to.eql({
64
61
  name: 'Test name',
65
62
  age: 20
66
63
  });
67
64
  });
68
65
 
69
66
  it('accepts an object as the key', () => {
70
- model.set( { placeOfBirth: 'London' } ).attributes.should.eql({
67
+ model.set( { placeOfBirth: 'London' } );
68
+ expect(model.attributes).to.eql({
69
+ name: 'Test name',
70
+ placeOfBirth: 'London'
71
+ });
72
+ });
73
+
74
+ it('accepts a Map as the key', () => {
75
+ model.set( new Map([['placeOfBirth', 'London']]));
76
+ expect(model.attributes).to.eql({
71
77
  name: 'Test name',
72
78
  placeOfBirth: 'London'
73
79
  });
@@ -141,17 +147,17 @@ describe('Local Model', () => {
141
147
 
142
148
  it('removes properties from model when passed a string', () => {
143
149
  model.unset('a');
144
- model.toJSON().should.eql({ b: 2, c: 3 });
150
+ expect(model.toJSON()).to.eql({ b: 2, c: 3 });
145
151
  });
146
152
 
147
153
  it('removes properties from model when passed an array', () => {
148
154
  model.unset(['a', 'b']);
149
- model.toJSON().should.eql({ c: 3 });
155
+ expect(model.toJSON()).to.eql({ c: 3 });
150
156
  });
151
157
 
152
158
  it('does nothing if passed a property that does not exist', () => {
153
159
  model.unset('foo');
154
- model.toJSON().should.eql({ a: 1, b: 2, c: 3 });
160
+ expect(model.toJSON()).to.eql({ a: 1, b: 2, c: 3 });
155
161
  });
156
162
 
157
163
  it('emits a change event', () => {
@@ -225,7 +231,7 @@ describe('Local Model', () => {
225
231
 
226
232
  it('clears model attributes', () => {
227
233
  model.reset();
228
- model.toJSON().should.eql({});
234
+ expect(model.toJSON()).to.eql({});
229
235
  expect(model.get('name')).to.be.undefined;
230
236
  expect(model.get('age')).to.be.undefined;
231
237
  });
@@ -244,9 +250,9 @@ describe('Local Model', () => {
244
250
  model.on('change:age', listener2);
245
251
  model.reset();
246
252
  listener1.should.have.been.calledOnce;
247
- listener1.should.have.been.calledWithExactly(undefined);
253
+ listener1.should.have.been.calledWithExactly(undefined, 'John');
248
254
  listener2.should.have.been.calledOnce;
249
- listener2.should.have.been.calledWithExactly(undefined);
255
+ listener2.should.have.been.calledWithExactly(undefined, 30);
250
256
  });
251
257
 
252
258
  it('emits no events if called with silent: true', () => {
@@ -266,10 +272,22 @@ describe('Local Model', () => {
266
272
  };
267
273
  });
268
274
 
269
- it('returns an object that\'s the same as the attributes property', () => {
270
- model.toJSON().should.eql({
275
+ it('returns a bare object that\'s the same as the attributes property', () => {
276
+ const result = model.toJSON(true);
277
+ expect(result).to.eql({
271
278
  name: 'Test name'
272
279
  });
280
+
281
+ expect(result.constructor).to.be.undefined;
282
+ });
283
+
284
+ it('returns an object that\'s the same as the attributes property with object prototype', () => {
285
+ const result = model.toJSON();
286
+ expect(result).to.eql({
287
+ name: 'Test name'
288
+ });
289
+
290
+ expect(result.constructor).to.be.a('function');
273
291
  });
274
292
  });
275
293