hmpo-model 3.2.0 → 4.0.1

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.
@@ -0,0 +1,21 @@
1
+ # This config is equivalent to both the '.circleci/extended/orb-free.yml' and the base '.circleci/config.yml'
2
+ version: 2.1
3
+
4
+ # Orbs are reusable packages of CircleCI configuration that you may share across projects, enabling you to create encapsulated, parameterized commands, jobs, and executors that can be used across multiple projects.
5
+ # See: https://circleci.com/docs/2.0/orb-intro/
6
+ orbs:
7
+ node: circleci/node@4.7
8
+
9
+ # Invoke jobs via workflows
10
+ # See: https://circleci.com/docs/2.0/configuration-reference/#workflows
11
+ workflows:
12
+ sample: # This is the name of the workflow, feel free to change it to better match your workflow.
13
+ # Inside the workflow, you define the jobs you want to run.
14
+ jobs:
15
+ - node/test:
16
+ # This is the node version to use for the `cimg/node` tag
17
+ # Relevant tags can be found on the CircleCI Developer Hub
18
+ # https://circleci.com/developer/images/image/cimg/node
19
+ version: '16.10'
20
+ # If you are using yarn, change the line below from "npm" to "yarn"
21
+ pkg-manager: npm
package/README.md CHANGED
@@ -2,88 +2,134 @@
2
2
  * localModel - Simple model for data persistance
3
3
  * remoteModel - Simple model for interacting with http/rest apis.
4
4
 
5
+ ## Upgrading
6
+
7
+ The deprecated `request` library has been replaced with `got`. The API is very similar, and some args are translated, like auth, and proxy.
8
+ The new `got` library doesn't automativally use the proxy environment variables so you would need to use something like `global-agent` in your
9
+ app if you need to specify proxies by environment arguments.
10
+
11
+ The `request` method no longer takes a body. This should be inserted as `json`, `body`, or `form` into the `requestConfig` method.
12
+
5
13
  ## Local Model Usage
6
- ### get
14
+ ### `get(name)`
7
15
  * gets a model property via a key
8
16
 
9
- ### set
17
+ ### `set(name, value)` or `set({ name: value })`
10
18
  * sets a property on the model to a value and dispatches events
11
19
 
12
- ### unset
20
+ ### `unset(name)`
13
21
  * unsets a property
14
22
 
15
- ### reset
23
+ ### `reset([options])`
16
24
  * resets a model
17
25
  * suppresses `change` event notifications if `options.silent` is set
18
26
 
19
- ### increment
27
+ ### `increment(name)`
20
28
  * Increments a property
21
29
 
22
- ### toJSON
30
+ ### `toJSON()`
23
31
  * returns a JSON representation of the data in the model
24
32
 
25
33
  ## Remote Model Usage
26
34
 
27
35
  Normally this would be used as an abstract class and extended with your own implementation.
28
36
 
29
- Implementations would normally define at least a `url` method to define the target of API calls.
30
-
31
- There are three methods for API interaction corresponding to GET, POST, and DELETE http methods:
32
-
33
- ### `fetch`
37
+ Implementations would normally define at least a `url():url` method to define the target of API calls.
34
38
 
39
+ Example implimentation:
35
40
  ```javascript
36
- var model = new Model();
37
- model.fetch(function (err, data, responseTime) {
41
+ class MyModel extends HmpoModel {
42
+ url() {
43
+ return super.url('https://my.example.com/url')
44
+ }
45
+
46
+ auth() {
47
+ return super.auth('username:password');
48
+ }
49
+
50
+ requestConfig(config) {
51
+ config.proxy = 'http://proxy.example.com:3128'
52
+ return super.requestConfig(config);
53
+ }
54
+
55
+ // add data to JSON post body
56
+ prepare(callback) {
57
+ super.prepare((err, data) => {
58
+ if (err) return callback(err);
59
+ data.foo = 'bar';
60
+ callback(null, data);
61
+ });
62
+ }
63
+
64
+ // transform returned data
65
+ parse(data) {
66
+ data.additionalItem = true;
67
+ return super.parse(data);
68
+ }
69
+ }
70
+
71
+ const model = new MyModel();
72
+ model.set('boo', 'baz');
73
+ model.save((err, data, responseTime) => {
74
+ if (err) return console.error(err);
38
75
  console.log(data);
39
76
  });
40
77
  ```
41
78
 
42
- ### `save`
79
+ There are three methods for API interaction corresponding to GET, POST, and DELETE http methods:
80
+
81
+ ### `fetch([args, ][callback])`
82
+
83
+ `fetch` performs a `GET` request on the url
43
84
 
44
85
  ```javascript
45
- var model = new Model();
46
- model.set({
47
- property: 'properties are sent as JSON request body by default'
48
- });
49
- model.save(function (err, data, responseTime) {
86
+ const model = new Model();
87
+ model.fetch((err, data, responseTime) => {
50
88
  console.log(data);
51
89
  });
52
90
  ```
91
+ #### Request
92
+ - Request args for the `got` library, can be set by overriding the `requestConfig({}):{}` method.
93
+
94
+ - The `url` can be configured either by setting a default in the model options or `requestConfig()` data, or by overriding the `url(default, args):url` method.
95
+
96
+ - `proxy`, `timeout`, and basic `auth` can be set in the same way, using model options, setting in `requestConfig()`, or by overriding a method.
97
+ - Specifying a `proxy` will set up a proxy tunneling `agent` for the request.
98
+ - Specifying a numeric `timeout` will set the same timeout for all `got` timeout values.
99
+ - Basic `auth` can be a colon separated string, or a `{username, password}` or `{user, pass}` object.
100
+
101
+ #### Response
102
+ - The returned body will be expected to be in JSON format.
103
+ - If `statusCode < 400` the JSON response will be set to the model.
104
+ This behaviour can be changed by overriding the `parse(data):data` method.
105
+ - If `statusCode >= 400` the data will be passed to the `parseError(statusCode, data):error` method, and the `fetch` callback will be called with the returned error.
106
+ - If response statuses need to be treated differently than the above, the `parseResponse(statusCode, data, cb)` method can be overridden.
107
+ - If the response body is not going to be JSON, the `handleResponse(response, cb)` method can be overridden.
108
+
109
+ ### `save([args, ][callback])`
53
110
 
54
- The method can also be overwritten by passing options
111
+ `save` performs a `POST` request on the url
55
112
 
56
113
  ```javascript
57
- var model = new Model();
114
+ const model = new Model();
58
115
  model.set({
59
- property: 'this will be sent as a PUT request'
116
+ property: 'properties are sent as JSON request body by default'
60
117
  });
61
- model.save({ method: 'PUT' }, function (err, data, responseTime) {
118
+ model.save((err, data, responseTime) => {
62
119
  console.log(data);
63
120
  });
64
121
  ```
65
122
 
66
- ### `delete`
123
+ - By default the post body will be a JSON encoded object containing all attributes set to the model using, extracted using `model.toJSON()`. This behaviour can be changed by overriding the `prepare(callback(err, data))` method.
124
+ - The response and body will be treated the same way as the `fetch` request above.
67
125
 
68
- ```javascript
69
- var model = new Model();
70
- model.delete(function (err, data) {
71
- console.log(data);
72
- });
73
- ```
126
+ ### `delete([args, ][callback])`
74
127
 
75
- If no `url` method is defined then the model will use the options parameter and [Node's url.format method](https://nodejs.org/api/url.html#url_url_format_urlobj) to construct a URL.
128
+ `delete` performs a `DELETE` request on the url
76
129
 
77
130
  ```javascript
78
- var model = new Model();
79
-
80
- // make a GET request to http://example.com:3000/foo/bar
81
- model.fetch({
82
- protocol: 'http',
83
- hostname: 'example.com',
84
- port: 3000,
85
- path: '/foo/bar'
86
- }, function (err, data, responseTime) {
131
+ const model = new Model();
132
+ model.delete((err, data, responseTime) => {
87
133
  console.log(data);
88
134
  });
89
135
  ```
@@ -1,6 +1,5 @@
1
1
  'use strict';
2
2
 
3
- const _ = require('underscore');
4
3
  const EventEmitter = require('events').EventEmitter;
5
4
 
6
5
  class LocalModel extends EventEmitter {
@@ -9,8 +8,8 @@ class LocalModel extends EventEmitter {
9
8
  super();
10
9
 
11
10
  this.options = options || {};
12
- this.attributes = {};
13
- this.set(attributes, {silent: true});
11
+ this.attributes = Object.create(null);
12
+ if (attributes) this.set(attributes, {silent: true});
14
13
  }
15
14
 
16
15
  get(key) {
@@ -19,32 +18,35 @@ class LocalModel extends EventEmitter {
19
18
 
20
19
  set(key, value, options) {
21
20
 
22
- let attrs = {};
23
-
24
- if (typeof key === 'string') {
21
+ let attrs = Object.create(null);
22
+ if (key instanceof Map) {
23
+ Object.assign(attrs, Object.fromEntries(key));
24
+ options = value;
25
+ } else if (typeof key === 'string') {
25
26
  attrs[key] = value;
26
27
  } else {
27
- attrs = key;
28
+ Object.assign(attrs, key);
28
29
  options = value;
29
30
  }
30
- options = options || {};
31
31
 
32
- let old = this.toJSON(),
33
- changed = {};
32
+ // silent set
33
+ if (options && options.silent) {
34
+ Object.assign(this.attributes, attrs);
35
+ return this;
36
+ }
34
37
 
35
- _.each(attrs, (value, key) => {
36
- if (value !== old[key]) {
37
- changed[key] = value;
38
- }
39
- });
38
+ const changes = [];
39
+ for (let key in attrs) {
40
+ const value = attrs[key];
41
+ const old = this.attributes[key];
42
+ if (value !== old) changes.push([key, value, old]);
43
+ }
40
44
 
41
- _.extend(this.attributes, attrs);
45
+ Object.assign(this.attributes, attrs);
42
46
 
43
- if (!options.silent && !_.isEmpty(changed)) {
44
- _.each(changed, (value, key) => {
45
- this.emit('change:' + key, this.get(key), old[key]);
46
- });
47
- this.emit('change', changed);
47
+ if (changes.length) {
48
+ changes.forEach(([key, value, old]) => this.emit('change:' + key, value, old));
49
+ if (this.listenerCount('change')) this.emit('change', Object.fromEntries(changes));
48
50
  }
49
51
 
50
52
  return this;
@@ -52,38 +54,32 @@ class LocalModel extends EventEmitter {
52
54
 
53
55
 
54
56
  unset(fields, options) {
55
- options = options || {};
56
57
  if (typeof fields === 'string') {
57
58
  fields = [fields];
58
59
  }
59
- let old = this.toJSON(),
60
- changed = {};
61
60
 
62
- _.each(fields, (key) => {
63
- if (old[key] !== undefined) {
64
- changed[key] = undefined;
61
+ const changes = [];
62
+ for (let key of fields) {
63
+ const old = this.attributes[key];
64
+ if (old !== undefined) {
65
+ changes.push([key, undefined, old]);
65
66
  delete this.attributes[key];
66
67
  }
67
- });
68
+ }
68
69
 
69
- if (!options.silent && !_.isEmpty(changed)) {
70
- _.each(changed, (value, key) => {
71
- this.emit('change:' + key, undefined, old[key]);
72
- });
73
- this.emit('change', changed);
70
+ if ((!options || !options.silent) && changes.length) {
71
+ changes.forEach(([key, value, old]) => this.emit('change:' + key, value, old));
72
+ if (this.listenerCount('change')) this.emit('change', Object.fromEntries(changes));
74
73
  }
75
74
 
76
75
  return this;
77
76
  }
78
77
 
79
78
  reset(options) {
80
- options = options || {};
81
- let keys = Object.keys(this.attributes);
82
- this.attributes = {};
83
- if (!options.silent) {
84
- _.each(keys, (key) => {
85
- this.emit('change:' + key, undefined);
86
- });
79
+ const old = this.attributes;
80
+ this.attributes = Object.create(null);
81
+ if (!options || !options.silent) {
82
+ Object.keys(old).forEach(key => this.emit('change:' + key, undefined, old[key]));
87
83
  this.emit('reset');
88
84
  }
89
85
  }
@@ -97,8 +93,8 @@ class LocalModel extends EventEmitter {
97
93
  this.set(property, val + amount);
98
94
  }
99
95
 
100
- toJSON() {
101
- return _.clone(this.attributes);
96
+ toJSON(bare = false) {
97
+ return Object.assign(bare ? Object.create(null) : {}, this.attributes);
102
98
  }
103
99
  }
104
100
 
@@ -1,91 +1,164 @@
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
+
9
+ const DEFAULT_TIMEOUT = 60000;
8
10
 
9
11
  class RemoteModel extends LocalModel {
10
12
  constructor(attributes, options) {
11
13
  super(attributes, options);
12
-
14
+ this.got = got;
13
15
  this.options.label = this.options.label || kebabCase(this.constructor.name);
14
16
  this.setLogger();
15
17
  }
16
18
 
17
19
  setLogger() {
18
- this.logger = hmpoLogger.get(':' + this.options.label);
20
+ try {
21
+ const hmpoLogger = require('hmpo-logger');
22
+ this.logger = hmpoLogger.get(':' + this.options.label);
23
+ } catch (e) {
24
+ console.error('Error setting logger, using console instead!', e);
25
+ this.logger = { outbound: console.log, trimHtml: html => html };
26
+ }
19
27
  }
20
28
 
21
- fetch(callback) {
22
- let config = this.requestConfig({method: 'GET'});
29
+ fetch(args, callback) {
30
+ if (typeof args === 'function') {
31
+ callback = args;
32
+ args = undefined;
33
+ }
34
+ const config = this.requestConfig({method: 'GET'}, args);
23
35
  this.request(config, callback);
24
36
  }
25
37
 
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
-
38
+ save(args, callback) {
39
+ if (typeof args === 'function') {
40
+ callback = args;
41
+ args = undefined;
42
+ }
43
+ this.prepare((err, json) => {
44
+ if (err) return callback(err);
45
+ const config = this.requestConfig({method: 'POST', json}, args);
46
+ this.request(config, callback);
43
47
  });
44
48
  }
45
49
 
46
- delete(callback) {
47
- let config = this.requestConfig({method: 'DELETE'});
50
+ delete(args, callback) {
51
+ if (typeof args === 'function') {
52
+ callback = args;
53
+ args = undefined;
54
+ }
55
+ const config = this.requestConfig({method: 'DELETE'}, args);
48
56
  this.request(config, callback);
49
57
  }
50
58
 
51
- requestConfig(config) {
52
- let retConfig = _.clone(config);
59
+ prepare(callback) {
60
+ debug('prepare');
61
+ callback(null, this.toJSON());
62
+ }
63
+
64
+ requestConfig(config, args) {
65
+ const retConfig = Object.assign({}, config);
53
66
 
54
- retConfig.uri = this.url();
67
+ retConfig.url = this.url(retConfig.url || retConfig.uri, args);
55
68
 
56
- let auth = this.auth();
69
+ retConfig.timeout = this.timeout(retConfig.timeout);
57
70
 
71
+ const auth = this.auth(retConfig.auth);
58
72
  if (auth) {
59
- retConfig.auth = auth;
73
+ retConfig.username = auth.username || auth.user;
74
+ retConfig.password = auth.password || auth.pass;
60
75
  }
76
+ delete retConfig.auth;
61
77
 
62
- if (this.options.headers) {
63
- retConfig.headers = _.defaults(retConfig.headers || {}, this.options.headers);
78
+ const agent = this.proxy(retConfig.proxy, retConfig.url);
79
+ if (agent) {
80
+ retConfig.agent = agent;
64
81
  }
82
+ delete retConfig.proxy;
83
+
84
+ const headers = Object.assign({}, this.options.headers, retConfig.headers);
85
+ if (Object.keys(headers).length) retConfig.headers = headers;
86
+
87
+ debug('requestConfig', retConfig);
65
88
 
66
89
  return retConfig;
67
90
  }
68
91
 
69
- request(settings, body, callback) {
70
- if (typeof body === 'function' && arguments.length === 2) {
71
- callback = body;
72
- body = undefined;
92
+ url(url = this.options.url) {
93
+ return url;
94
+ }
95
+
96
+ auth(auth = this.options.auth) {
97
+ if (typeof auth === 'string') {
98
+ const splitAuth = auth.split(':');
99
+ auth = {
100
+ username: splitAuth.shift(),
101
+ password: splitAuth.join(':')
102
+ };
73
103
  }
104
+ return auth;
105
+ }
74
106
 
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;
107
+ timeout(timeout = this.options.timeout || DEFAULT_TIMEOUT) {
108
+ if (typeof timeout === 'number') {
109
+ timeout = {
110
+ lookup: timeout,
111
+ connect: timeout,
112
+ secureConnect: timeout,
113
+ socket: timeout,
114
+ send: timeout,
115
+ response: timeout
116
+ };
117
+ }
118
+ return timeout;
119
+ }
120
+
121
+ proxy(proxy = this.options.proxy, url) {
122
+ if (!proxy || !url) return;
123
+
124
+ if (typeof proxy === 'string') proxy = { proxy };
125
+
126
+ const isHttps = (new URL(url).protocol === 'https:');
79
127
 
80
- let startTime = process.hrtime();
128
+ if (isHttps) {
129
+ const { HttpsProxyAgent } = require('hpagent');
130
+ return {
131
+ https: new HttpsProxyAgent(Object.assign({
132
+ keepAlive: false,
133
+ maxSockets: 1,
134
+ maxFreeSockets: 1,
135
+ }, proxy))
136
+ };
137
+ } else {
138
+ const { HttpProxyAgent } = require('hpagent');
139
+ return {
140
+ http: new HttpProxyAgent(Object.assign({
141
+ keepAlive: false,
142
+ maxSockets: 1,
143
+ maxFreeSockets: 1,
144
+ }, proxy))
145
+ };
146
+ }
147
+ }
81
148
 
82
- let _callback = (err, data, statusCode) => {
149
+ request(settings, callback) {
150
+ this.hookSync({settings});
151
+ this.logSync({settings});
152
+ this.emit('sync', settings);
83
153
 
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));
154
+ let responseTime;
155
+ const startTime = process.hrtime.bigint();
156
+ const setResponseTime = () => {
157
+ const endTime = process.hrtime.bigint();
158
+ responseTime = Number((Number(endTime - startTime) / 1000000).toFixed(3));
159
+ };
88
160
 
161
+ const _callback = (err, data, statusCode) => {
89
162
  if (err) {
90
163
  this.hookFail({settings, statusCode, responseTime, err, data});
91
164
  this.logError({settings, statusCode, responseTime, err, data});
@@ -100,25 +173,27 @@ class RemoteModel extends LocalModel {
100
173
  }
101
174
  };
102
175
 
103
- requestLib(requestSettings, (err, response) => {
104
- if (err) {
176
+ this.got(settings)
177
+ .catch(err => {
178
+ debug('request got error', err);
179
+ setResponseTime();
105
180
  if (err.code === 'ETIMEDOUT') {
106
181
  err.message = 'Connection timed out';
107
182
  err.status = 504;
108
183
  }
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);
184
+ err.status = err.status || (err.response && err.response.statusCode) || 503;
185
+ return _callback(err, null, err.status, err.response);
186
+ })
187
+ .then(response => {
188
+ debug('request got response', response);
189
+ setResponseTime();
190
+ this.handleResponse(response, _callback);
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) {
@@ -130,53 +205,43 @@ class RemoteModel extends LocalModel {
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
  }