@vida-global/core 2.4.5 → 3.0.0

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.
Files changed (37) hide show
  1. package/README.md +1 -0
  2. package/index.js +6 -1
  3. package/lib/activeRecord/README.md +4 -2
  4. package/lib/activeRecord/baseRecord.js +2 -2
  5. package/lib/activeRecord/db/migrationErrors.js +6 -0
  6. package/lib/activeRecord/db/migrator.js +27 -10
  7. package/lib/http/README.md +18 -4
  8. package/lib/http/client.js +13 -9
  9. package/lib/jobQueue/README.md +4 -0
  10. package/lib/jobQueue/queue.js +7 -0
  11. package/lib/search/README.md +232 -0
  12. package/lib/search/abstractSearchClient.js +188 -0
  13. package/lib/search/errors.js +31 -0
  14. package/lib/search/index.js +16 -0
  15. package/lib/search/openSearchClient.js +319 -0
  16. package/lib/search/query.js +163 -0
  17. package/lib/server/authorizer.js +42 -0
  18. package/lib/server/controllerMixins/validations.js +1 -0
  19. package/lib/server/index.js +3 -2
  20. package/lib/server/serverController.js +37 -3
  21. package/package.json +2 -1
  22. package/test/activeRecord/baseRecord.test.js +22 -0
  23. package/test/activeRecord/db/migrator.test.js +17 -5
  24. package/test/activeRecord/db/schema.test.js +15 -0
  25. package/test/http/client.test.js +42 -0
  26. package/test/http/helpers/client.js +15 -1
  27. package/test/jobQueue/helpers/bullmqMock.js +1 -0
  28. package/test/jobQueue/queue.test.js +11 -0
  29. package/test/search/abstractSearchClient.test.js +354 -0
  30. package/test/search/errors.test.js +41 -0
  31. package/test/search/helpers/searchClient.js +137 -0
  32. package/test/search/openSearchClient.test.js +514 -0
  33. package/test/search/query.test.js +286 -0
  34. package/test/search/serializer.test.js +202 -0
  35. package/test/server/authorizer.test.js +105 -0
  36. package/test/server/helpers/authorizer.js +19 -0
  37. package/test/server/serverController.test.js +77 -0
@@ -0,0 +1,163 @@
1
+ class Query {
2
+ #client;
3
+ #conditions;
4
+
5
+
6
+ constructor(client, conditions) {
7
+ this.#client = client;
8
+ this.#conditions = conditions || this.constructor.EMPTY_CONDITIONS;
9
+ }
10
+
11
+
12
+ static get EMPTY_CONDITIONS() {
13
+ return {and: [], or: [], not: [], options: {}};
14
+ }
15
+
16
+
17
+ static get DEFAULTS() {
18
+ return {BOOST: 1, FUZZINESS: 'AUTO', SORT_DIRECTION: 'desc'};
19
+ }
20
+
21
+
22
+ async fetch(options) {
23
+ return await this.#client.fetch(this.conditions, options);
24
+ }
25
+
26
+
27
+ get conditions() {
28
+ return structuredClone(this.#conditions);
29
+ }
30
+
31
+
32
+ /***********************************************************************************************
33
+ * OPERATORS
34
+ ***********************************************************************************************/
35
+ and(...newConditions) {
36
+ return this.#applyConditions('and', newConditions);
37
+ }
38
+
39
+
40
+ or(...newConditions) {
41
+ return this.#applyConditions('or', newConditions);
42
+ }
43
+
44
+
45
+ not(...newConditions) {
46
+ return this.#applyConditions('not', newConditions);
47
+ }
48
+
49
+
50
+ /***********************************************************************************************
51
+ * CONDITIONS
52
+ ***********************************************************************************************/
53
+ equals(field, value) {
54
+ return this.and({operator: 'equals', field, value});
55
+ }
56
+
57
+
58
+ in(field, values) {
59
+ return this.and({operator: 'in', field, value: values});
60
+ }
61
+
62
+
63
+ range(field, bounds) {
64
+ return this.and({operator: 'range', field, value: bounds});
65
+ }
66
+
67
+
68
+ exists(field) {
69
+ return this.and({operator: 'exists', field});
70
+ }
71
+
72
+
73
+ missing(field) {
74
+ return this.and({operator: 'missing', field});
75
+ }
76
+
77
+
78
+ matches(field, text, options = {}) {
79
+ const fuzziness = options.fuzziness || this.constructor.DEFAULTS.FUZZINESS;
80
+ return this.and({operator: 'matches', field, value: text, options: {fuzziness}});
81
+ }
82
+
83
+
84
+ phrase(field, text) {
85
+ return this.and({operator: 'phrase', field, value: text});
86
+ }
87
+
88
+
89
+ fullText(text, options = {}) {
90
+ const field = this.#buildBoostMap(options);
91
+ const wildcard = Boolean(options.wildcard);
92
+ return this.and({operator: 'fullText', field, value: text, options: {wildcard}});
93
+ }
94
+
95
+
96
+ nested(path, query) {
97
+ return this.and({operator: 'nested', field: path, value: query.conditions});
98
+ }
99
+
100
+
101
+ /***********************************************************************************************
102
+ * OPTIONS
103
+ ***********************************************************************************************/
104
+ limit(value) {
105
+ return this.#applyOption('limit', value);
106
+ }
107
+
108
+
109
+ offset(value) {
110
+ return this.#applyOption('offset', value);
111
+ }
112
+
113
+
114
+ minScore(value) {
115
+ return this.#applyOption('minScore', value);
116
+ }
117
+
118
+
119
+ sort(field, direction) {
120
+ const sortKey = {field, direction: direction || this.constructor.DEFAULTS.SORT_DIRECTION};
121
+ const sortKeys = [...(this.#conditions.options.sort || []), sortKey];
122
+ return this.#applyOption('sort', sortKeys);
123
+ }
124
+
125
+
126
+ /***********************************************************************************************
127
+ * CONDITION ASSEMBLY
128
+ ***********************************************************************************************/
129
+ #applyConditions(key, newConditions) {
130
+ const conditions = this.conditions;
131
+ for (const newCondition of newConditions) {
132
+ conditions[key].push(this.#normalizeCondition(newCondition));
133
+ }
134
+ return new this.constructor(this.#client, conditions);
135
+ }
136
+
137
+
138
+ #normalizeCondition(newCondition) {
139
+ if (newCondition instanceof Query) return newCondition.conditions;
140
+ return newCondition;
141
+ }
142
+
143
+
144
+ #applyOption(key, value) {
145
+ const conditions = this.conditions;
146
+ conditions.options[key] = value;
147
+ return new this.constructor(this.#client, conditions);
148
+ }
149
+
150
+
151
+ #buildBoostMap({fields, boost = {}}) {
152
+ const boostMap = {};
153
+ for (const field of fields) {
154
+ boostMap[field] = boost[field] || this.constructor.DEFAULTS.BOOST;
155
+ }
156
+ return boostMap;
157
+ }
158
+ }
159
+
160
+
161
+ module.exports = {
162
+ Query
163
+ };
@@ -0,0 +1,42 @@
1
+ const { camelize } = require('inflection');
2
+
3
+
4
+ class Authorizer {
5
+ #currentUser;
6
+ #adminUser;
7
+
8
+ constructor(currentUser, adminUser) {
9
+ this.#currentUser = currentUser;
10
+ this.#adminUser = adminUser;
11
+ }
12
+
13
+
14
+ async can(action, scope) {
15
+ if (!scope) return false;
16
+
17
+ const cls = this.classForScope(scope);
18
+ let permissionMethod = camelize(`${cls.name}Permission`, true);
19
+ if (!this[permissionMethod]) permissionMethod = 'defaultPermission';
20
+
21
+ return await this[permissionMethod](action, scope);
22
+ }
23
+
24
+
25
+ defaultPermission(action, scope) {
26
+ return false;
27
+ }
28
+
29
+
30
+ classForScope(scope) {
31
+ return !scope || scope.constructor == Function ? scope : scope.constructor;
32
+ }
33
+
34
+
35
+ get currentUser() { return this.#currentUser; }
36
+ get adminUser() { return this.#adminUser; }
37
+ }
38
+
39
+
40
+ module.exports = {
41
+ Authorizer
42
+ }
@@ -38,6 +38,7 @@ const InstanceMethods = {
38
38
  const errors = [];
39
39
 
40
40
  if (parameterValidations?.optional && value === undefined) return errors;
41
+ if (parameterValidations?.nullable === true && value === null) return errors;
41
42
 
42
43
  for (const [validationType, validationOptions] of Object.entries(parameterValidations)) {
43
44
  const validationMethod = `validate${camelize(validationType)}`;
@@ -1,7 +1,8 @@
1
+ const Errors = require('./errors');
2
+ const { ApiDocsGenerator } = require('./openApi/apiDocsGenerator');
3
+ const { Authorizer } = require('./authorizer');
1
4
  const { VidaServer } = require('./server');
2
5
  const { VidaServerController } = require('./serverController');
3
- const Errors = require('./errors');
4
- const { ApiDocsGenerator } = require('./openApi/apiDocsGenerator');
5
6
 
6
7
 
7
8
  module.exports = {
@@ -1,11 +1,13 @@
1
- const APM = require('../apm');
2
- const { camelize } = require('inflection');
3
- const crypto = require('node:crypto');
1
+ const APM = require('../apm');
2
+ const { Authorizer } = require('./authorizer');
3
+ const { camelize } = require('inflection');
4
+ const crypto = require('node:crypto');
4
5
  const Errors = require('./errors');
5
6
  const { logger } = require('../logger');
6
7
 
7
8
 
8
9
  class VidaServerController {
10
+ #authorizer;
9
11
  #logger;
10
12
  #params;
11
13
  #rendered = false;
@@ -207,6 +209,38 @@ class VidaServerController {
207
209
  }
208
210
 
209
211
 
212
+ /***********************************************************************************************
213
+ * AUTH
214
+ ***********************************************************************************************/
215
+ async authorize(action, scope) {
216
+ const authorized = await this.can(action, scope);
217
+ if (!authorized) throw new this.Errors.Forbidden();
218
+ }
219
+
220
+
221
+ async can(action, scope) {
222
+ return await this.authorizer.can(action, scope);
223
+ }
224
+
225
+
226
+ get authorizer() {
227
+ if (!this.#authorizer) {
228
+ this.#authorizer = new this.authorizerClass(...this.authorizerParameters);
229
+ }
230
+ return this.#authorizer;
231
+ }
232
+
233
+
234
+ get authorizerParameters() {
235
+ return [this.currentUser, this.adminUser];
236
+ }
237
+
238
+
239
+ get authorizerClass() {
240
+ return Authorizer;
241
+ }
242
+
243
+
210
244
  /***********************************************************************************************
211
245
  * SECURITY
212
246
  ***********************************************************************************************/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.4.5",
3
+ "version": "3.0.0",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -31,6 +31,7 @@
31
31
  "js-yaml": "^3.14.2",
32
32
  "mustache-express": "^1.2.8",
33
33
  "newrelic": "^13.0.0",
34
+ "@opensearch-project/opensearch": "^3.0.0",
34
35
  "pg": "^8.16.3",
35
36
  "redis": "^4.0.0",
36
37
  "response-time": "^2.3.3",
@@ -655,6 +655,28 @@ describe('BaseRecord', () => {
655
655
  expect(Person.findAll).toHaveBeenCalledWith({where: {[pk]: [id1, id2]}});
656
656
  });
657
657
 
658
+ it ('sets each record with an expiry when expireSeconds is passed', async () => {
659
+ const key1 = `Person#find:${id1}`;
660
+ Person.findAll.mockImplementation(() => [new Person({[pk]:id1})]);
661
+ redisClient.mGet.mockImplementation(keys => [null]);
662
+
663
+ await Person._cachedFind([id1], {expireSeconds: 300});
664
+
665
+ expect(redisClient.mSet).not.toHaveBeenCalled();
666
+ expect(redisClient.set).toHaveBeenCalledTimes(1);
667
+ expect(redisClient.set).toHaveBeenCalledWith(key1, `AR:Person:{"${pk}":${id1}}`, {EX: 300});
668
+ });
669
+
670
+ it ('passes expireSeconds through from find', async () => {
671
+ const cacheSpy = jest.spyOn(Person, 'cachedFetch');
672
+
673
+ await Person.find([id1], {expireSeconds: 300});
674
+
675
+ expect(cacheSpy).toHaveBeenCalledWith(expect.any(Array), {clear: false, expireSeconds: 300},
676
+ expect.any(Function));
677
+ cacheSpy.mockRestore();
678
+ });
679
+
658
680
  it ('returns an array of active record objects if an array was passed', async () => {
659
681
  const p1 = new Person({[pk]: id1});
660
682
  const p2 = new Person({[pk]: id2});
@@ -1,4 +1,5 @@
1
- const Helpers = require('../helpers/migrator');
1
+ const Helpers = require('../helpers/migrator');
2
+ const { MigrationError } = require('../../../lib/activeRecord/db/migrationErrors');
2
3
 
3
4
 
4
5
  afterEach(() => {
@@ -422,13 +423,13 @@ describe('Migrator', () => {
422
423
 
423
424
  describe('Migrator#removeIndex', () => {
424
425
  it.each([
425
- ['default options', false, { transaction: null }],
426
- ['concurrently', true, { concurrently: true, transaction: null }],
427
- ])('passes removeIndex options for %s', async (_label, concurrently, expectedOptions) => {
426
+ ['default options', {}, { transaction: null }],
427
+ ['concurrently', { concurrently: true }, { concurrently: true, transaction: null }],
428
+ ])('passes removeIndex options for %s', async (_label, indexOptions, expectedOptions) => {
428
429
  const tableName = Helpers.randomString();
429
430
  const fields = [Helpers.randomString(), Helpers.randomString()];
430
431
 
431
- await Helpers.migrator.removeIndex(tableName, fields, concurrently);
432
+ await Helpers.migrator.removeIndex(tableName, fields, indexOptions);
432
433
 
433
434
  expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledTimes(1);
434
435
  expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledWith(
@@ -453,6 +454,17 @@ describe('Migrator', () => {
453
454
  });
454
455
 
455
456
 
457
+ describe('Migrator concurrent indexes inside a transaction', () => {
458
+ it.each([
459
+ ['addIndex', async function() { await this.addIndex('users', ['team_id'], { concurrently: true }); }],
460
+ ['removeIndex', async function() { await this.removeIndex('users', ['team_id'], { concurrently: true }); }],
461
+ ])('throws when %s asks for concurrently', async (methodName, operation) => {
462
+ await expect(Helpers.runInMigration(operation)).rejects.toThrow(MigrationError);
463
+ expect(Helpers.mockQueryInterface[methodName]).not.toHaveBeenCalled();
464
+ });
465
+ });
466
+
467
+
456
468
  describe('Migrator#defaultIndexName', () => {
457
469
  it ('builds idx_<table>_<columns> from a single column', () => {
458
470
  expect(Helpers.migrator.defaultIndexName('users', ['team_id'])).toBe('idx_users_team_id');
@@ -122,6 +122,21 @@ describe('activeRecord/db/schema', () => {
122
122
  const schema = getActiveRecordSchema('t', 'main', 'postgres');
123
123
  expect(schema.name.autoIncrement).toBeUndefined();
124
124
  });
125
+
126
+
127
+ // A schema file is written from describeTable, and sequelize's postgres driver returns
128
+ // a BOOLEAN column's default as a javascript boolean rather than as text. Matching the
129
+ // sequence pattern against that boolean threw until e3dae1b replaced String#match with
130
+ // RegExp#test.
131
+ it.each([true, false, 5])('leaves a non-string defaultValue of %p alone', (defaultValue) => {
132
+ importSchema.mockImplementation(() => ({
133
+ flag: { type: 'BOOLEAN', defaultValue, allowNull: false }
134
+ }));
135
+ const schema = getActiveRecordSchema('t', 'main', 'postgres');
136
+ expect(schema.flag.autoIncrement).toBeUndefined();
137
+ expect(schema.flag.defaultValue).toBe(defaultValue);
138
+ expect(schema.flag.allowNull).toBe(false);
139
+ });
125
140
  });
126
141
 
127
142
 
@@ -181,6 +181,48 @@ describe('HttpClient', () => {
181
181
  });
182
182
 
183
183
 
184
+ describe('binary responses', () => {
185
+ const client = new HttpClient();
186
+
187
+
188
+ beforeEach(() => Helpers.mockFetchBinary());
189
+
190
+
191
+ it ('returns the exact bytes as a Buffer', async () => {
192
+ const result = await client.get(Helpers.apiUrl, { binaryResponse: true });
193
+
194
+ expect(result.status).toBe(200);
195
+ expect(Buffer.isBuffer(result.data)).toBe(true);
196
+ expect(result.data).toEqual(Helpers.binaryPayload);
197
+ });
198
+
199
+
200
+ // The point of the option: a text read replaces every byte that is not valid UTF-8.
201
+ it ('never reads the body as text', async () => {
202
+ await client.get(Helpers.apiUrl, { binaryResponse: true });
203
+
204
+ const response = await fetch.mock.results[0].value;
205
+ expect(response.text).not.toHaveBeenCalled();
206
+ });
207
+
208
+
209
+ it ('reads the body as text without the option, losing the bytes', async () => {
210
+ const result = await client.get(Helpers.apiUrl);
211
+
212
+ expect(typeof result.data).toBe('string');
213
+ expect(Buffer.from(result.data)).not.toEqual(Helpers.binaryPayload);
214
+ });
215
+
216
+
217
+ const verbs = [['post'], ['put'], ['delete']];
218
+ it.each(verbs)('applies to %s as well', async (methodName) => {
219
+ const result = await client[methodName](Helpers.apiUrl, { binaryResponse: true });
220
+
221
+ expect(result.data).toEqual(Helpers.binaryPayload);
222
+ });
223
+ });
224
+
225
+
184
226
  describe('abort/timeout support', () => {
185
227
  const client = new HttpClient();
186
228
 
@@ -18,6 +18,10 @@ const apiUrl = `${baseUrl}${endpoint}`;
18
18
  const responsePayload = randomPayload();
19
19
  const responseBody = JSON.stringify(responsePayload);
20
20
 
21
+ // A RIFF header followed by byte sequences that are not valid UTF-8, so a text read cannot
22
+ // reproduce them.
23
+ const binaryPayload = Buffer.from([0x52, 0x49, 0x46, 0x46, 0xff, 0xfe, 0x00, 0x80]);
24
+
21
25
 
22
26
  class ClientWithUrlRoot extends HttpClient {
23
27
  get urlRoot() {
@@ -67,11 +71,19 @@ const mockFetchResponse = ({ body, contentType, status = 200 }) => {
67
71
  get: jest.fn((headerName) => headerName == 'content-type' ? contentType : undefined),
68
72
  },
69
73
  status,
70
- text: jest.fn().mockResolvedValue(body),
74
+ // Interpolated rather than passed through, so a Buffer body decodes the way a real text
75
+ // read would.
76
+ text: jest.fn().mockResolvedValue(`${body ?? ''}`),
77
+ arrayBuffer: jest.fn().mockResolvedValue(Buffer.from(body ?? '')),
71
78
  });
72
79
  }
73
80
 
74
81
 
82
+ const mockFetchBinary = () => {
83
+ mockFetchResponse({ body: binaryPayload, contentType: 'audio/wav' });
84
+ }
85
+
86
+
75
87
  /***************************************************************************************************
76
88
  * ASSERTIONS
77
89
  ***************************************************************************************************/
@@ -109,11 +121,13 @@ module.exports = {
109
121
  apiUrl,
110
122
  basicAuthCredentials,
111
123
  bearerToken,
124
+ binaryPayload,
112
125
  ClientWithBasicAuth,
113
126
  ClientWithBearerToken,
114
127
  ClientWithUrlRoot,
115
128
  endpoint,
116
129
  expectRequest,
130
+ mockFetchBinary,
117
131
  mockFetchResponse,
118
132
  mockFetchSuccess,
119
133
  randomPayload,
@@ -16,6 +16,7 @@ class MockBullQueue {
16
16
  this.close = jest.fn();
17
17
  this.pause = jest.fn();
18
18
  this.resume = jest.fn();
19
+ this.obliterate = jest.fn();
19
20
  this.getJobCounts = jest.fn();
20
21
  this.getJobs = jest.fn();
21
22
  this.getActive = jest.fn();
@@ -218,6 +218,17 @@ describe('Queue', () => {
218
218
  });
219
219
 
220
220
 
221
+ describe('#obliterate', () => {
222
+ it ('calls bullmq obliterate() with force so active jobs do not block it', async () => {
223
+ await queue.obliterate();
224
+
225
+ const instance = bullmqMock.instances.queues[0];
226
+ expect(instance.obliterate).toHaveBeenCalledTimes(1);
227
+ expect(instance.obliterate).toHaveBeenCalledWith({ force: true });
228
+ });
229
+ });
230
+
231
+
221
232
  describe('maximum concurrency', () => {
222
233
  it ('sets the queue maximum', async () => {
223
234
  const maximum = TestHelpers.Faker.Math.randomNumber(20);