@vida-global/core 2.5.0 → 3.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,319 @@
1
+ const { AbstractSearchClient } = require('./abstractSearchClient');
2
+ const { AwsSigv4Signer } = require('@opensearch-project/opensearch/aws');
3
+ const { BulkWriteError, UnsupportedOperationError } = require('./errors');
4
+ const { Client } = require('@opensearch-project/opensearch');
5
+
6
+
7
+ class OpenSearchClient extends AbstractSearchClient {
8
+ #_client;
9
+
10
+
11
+ static get SERVICE() { return 'aoss'; }
12
+
13
+
14
+ get wildcardIndex() { return `${this.index}-*`; }
15
+ get endpoint() { throw new Error('Not Implemented') }
16
+
17
+
18
+ get writeIndex() {
19
+ if (!this.indexSuffix) return this.index;
20
+ return `${this.index}-${this.indexSuffix}`;
21
+ }
22
+
23
+
24
+ /***********************************************************************************************
25
+ * READ
26
+ ***********************************************************************************************/
27
+ async _execute(serializedConditions, options) {
28
+ return await this.#client.search({
29
+ index: this.#readIndexForFetch(options),
30
+ body: serializedConditions
31
+ });
32
+ }
33
+
34
+
35
+ _hitsFromResponse(response) { return response.body.hits }
36
+ _totalFromResponse(response) { return response.body.hits.total?.value || 0 }
37
+ _idFromHit(hit) { return hit._id }
38
+ _scoreFromHit(hit) { return hit._score }
39
+ _documentFromHit(hit) { return hit._source }
40
+
41
+
42
+ #readIndexForFetch({indexWildcard}) {
43
+ return indexWildcard ? this.wildcardIndex : this.writeIndex;
44
+ }
45
+
46
+
47
+ /***********************************************************************************************
48
+ * SERIALIZATION
49
+ ***********************************************************************************************/
50
+ _serializeConditions(conditions) {
51
+ return {
52
+ query: this.#serializeTree(conditions),
53
+ track_total_hits: true,
54
+ ...this.#serializeOptions(conditions.options)
55
+ };
56
+ }
57
+
58
+
59
+ #serializeTree(conditions) {
60
+ const must = this.#serializeAll(conditions.and);
61
+ const mustNot = this.#serializeAll(conditions.not);
62
+ if (conditions.or.length) must.push(this.#serializeShouldGroup(conditions.or));
63
+
64
+ if (must.length === 1 && !mustNot.length) return must[0];
65
+ return {bool: {must, must_not: mustNot}};
66
+ }
67
+
68
+
69
+ #serializeAll(conditions) {
70
+ return conditions.map(condition => this.#serializeCondition(condition));
71
+ }
72
+
73
+
74
+ // `minimum_should_match` is not optional here: alongside a populated `must`, OpenSearch treats
75
+ // `should` clauses as score boosters rather than filters, so the group would stop filtering.
76
+ #serializeShouldGroup(conditions) {
77
+ return {bool: {should: this.#serializeAll(conditions), minimum_should_match: 1}};
78
+ }
79
+
80
+
81
+ #serializeCondition(condition) {
82
+ if (condition.operator) return this.#serializeLeaf(condition);
83
+ return this.#serializeTree(condition);
84
+ }
85
+
86
+
87
+ #serializeLeaf(leaf) {
88
+ const operator = `${leaf.operator[0].toUpperCase()}${leaf.operator.slice(1)}`;
89
+ return this[`_serialize${operator}`](leaf);
90
+ }
91
+
92
+
93
+ #serializeOptions({limit, minScore, offset, sort}) {
94
+ const body = {};
95
+
96
+ if (limit !== undefined) body.size = limit;
97
+ if (offset !== undefined) body.from = offset;
98
+ if (minScore !== undefined) body.min_score = minScore;
99
+ if (sort) body.sort = this.#serializeSort(sort);
100
+ return body;
101
+ }
102
+
103
+
104
+ #serializeSort(sortKeys) {
105
+ return sortKeys.map(({field, direction}) => ({[field]: {order: direction}}));
106
+ }
107
+
108
+
109
+ /***********************************************************************************************
110
+ * CONDITION SERIALIZERS
111
+ ***********************************************************************************************/
112
+ _serializeEquals({field, value}) {
113
+ return {term: {[field]: {value}}};
114
+ }
115
+
116
+
117
+ _serializeIn({field, value}) {
118
+ return {terms: {[field]: value}};
119
+ }
120
+
121
+
122
+ _serializeRange({field, value}) {
123
+ return {range: {[field]: value}};
124
+ }
125
+
126
+
127
+ _serializeExists({field}) {
128
+ return {exists: {field}};
129
+ }
130
+
131
+
132
+ _serializeMissing({field}) {
133
+ return {bool: {must_not: {exists: {field}}}};
134
+ }
135
+
136
+
137
+ _serializeMatches({field, value, options}) {
138
+ return {match: {[field]: {query: value, fuzziness: options.fuzziness}}};
139
+ }
140
+
141
+
142
+ _serializePhrase({field, value}) {
143
+ return {match_phrase: {[field]: {query: value}}};
144
+ }
145
+
146
+
147
+ _serializeFullText({field, value, options}) {
148
+ const query = options.wildcard ? `*${value}*` : value;
149
+ const fields = Object.entries(field).map(([name, boost]) => `${name}^${boost}`);
150
+
151
+ return {query_string: {query, fields}};
152
+ }
153
+
154
+
155
+ _serializeNested({field, value}) {
156
+ return {nested: {path: field, query: this.#serializeTree(value)}};
157
+ }
158
+
159
+
160
+ /***********************************************************************************************
161
+ * WRITE
162
+ ***********************************************************************************************/
163
+ async _upsert(id, document) {
164
+ return await this.#rethrowUnsupported('upsert', () => this.#client.update({
165
+ id,
166
+ index: this.writeIndex,
167
+ body: {doc: document, doc_as_upsert: true}
168
+ }));
169
+ }
170
+
171
+
172
+ async _append(document) {
173
+ return await this.#client.index({index: this.writeIndex, body: document});
174
+ }
175
+
176
+
177
+ async _delete(id) {
178
+ return await this.#client.delete({id, index: this.writeIndex});
179
+ }
180
+
181
+
182
+ async _bulkUpsert(documents) {
183
+ const body = this.#buildBulkBody(documents);
184
+ const response = await this.#rethrowUnsupported('bulkUpsert', () => this.#client.bulk({body}));
185
+ this.#assertBulkSucceeded(response);
186
+ return response;
187
+ }
188
+
189
+
190
+ #buildBulkBody(documents) {
191
+ const operations = [];
192
+
193
+ for (const { id, document } of documents) {
194
+ operations.push({update: {_id: id, _index: this.writeIndex}});
195
+ operations.push({doc: document, doc_as_upsert: true});
196
+ }
197
+ return operations;
198
+ }
199
+
200
+
201
+ #assertBulkSucceeded(response) {
202
+ if (!response.body.errors) return;
203
+
204
+ const failures = this.#collectBulkFailures(response.body.items);
205
+ throw new BulkWriteError(`${failures.length} documents could not be written`, failures);
206
+ }
207
+
208
+
209
+ #collectBulkFailures(items) {
210
+ const failures = [];
211
+
212
+ for (const item of items) {
213
+ const outcome = item.update;
214
+ if (outcome.error) failures.push({id: outcome._id, reason: outcome.error.reason});
215
+ }
216
+ return failures;
217
+ }
218
+
219
+
220
+ async #rethrowUnsupported(operationName, operation) {
221
+ try {
222
+ return await operation();
223
+ } catch (error) {
224
+ if (!this._isUnsupportedOperation(error)) throw error;
225
+ throw new UnsupportedOperationError(
226
+ `${operationName} is not supported by the collection behind ${this.constructor.name}`,
227
+ {cause: error}
228
+ );
229
+ }
230
+ }
231
+
232
+
233
+ // Serverless rejects every write addressed to a document id unless the collection is of type
234
+ // SEARCH. Nothing in the response names the collection type, so the refusal is read off the
235
+ // message. The regex here is a guess and should be fixed if it doesn't properly catch the error
236
+ _isUnsupportedOperation(error) {
237
+ const statusCode = error.statusCode || error.meta?.statusCode;
238
+ if (statusCode !== 400) return false;
239
+ return /not supported/i.test(error.message || '');
240
+ }
241
+
242
+
243
+ /***********************************************************************************************
244
+ * ADMIN
245
+ ***********************************************************************************************/
246
+ async _indexExists() {
247
+ const response = await this.#client.indices.exists({index: this.writeIndex});
248
+ return Boolean(response.body);
249
+ }
250
+
251
+
252
+ async _createIndex() {
253
+ return await this.#client.indices.create({index: this.writeIndex});
254
+ }
255
+
256
+
257
+ async _putMapping() {
258
+ return await this.#client.indices.putMapping({index: this.writeIndex, body: this.mapping});
259
+ }
260
+
261
+
262
+ /***********************************************************************************************
263
+ * RETRIES
264
+ ***********************************************************************************************/
265
+ shouldRetry(attemptCount, error) {
266
+ if (!super.shouldRetry(attemptCount, error)) return false;
267
+ return this.#isServerError(error);
268
+ }
269
+
270
+
271
+ #isServerError(error) {
272
+ const statusCode = error.statusCode || error.meta?.statusCode;
273
+ return statusCode >= 500 && statusCode < 600;
274
+ }
275
+
276
+
277
+ /***********************************************************************************************
278
+ * CLIENT CONFIG
279
+ ***********************************************************************************************/
280
+ get openSearchClientClass() { return Client; }
281
+ get awsSignerFactory() { return AwsSigv4Signer; }
282
+
283
+
284
+ get #client() {
285
+ return this.#_client ||= new this.openSearchClientClass(this.#config);
286
+ }
287
+
288
+
289
+ get #config() {
290
+ return {...this.awsSigner, node: this.endpoint};
291
+ }
292
+
293
+
294
+ get awsSigner() {
295
+ return this.awsSignerFactory({
296
+ region: this.region,
297
+ service: this.constructor.SERVICE,
298
+ getCredentials: this.getCredentials.bind(this)
299
+ });
300
+ }
301
+
302
+
303
+ get region() {
304
+ return process.env.OPENSEARCH_REGION || process.env.DEFAULT_AWS_REGION;
305
+ }
306
+
307
+
308
+ async getCredentials() {
309
+ return {
310
+ accessKeyId: process.env.OPENSEARCH_AWS_KEY,
311
+ secretAccessKey: process.env.OPENSEARCH_AWS_SECRET
312
+ };
313
+ }
314
+ }
315
+
316
+
317
+ module.exports = {
318
+ OpenSearchClient
319
+ };
@@ -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
+ }
@@ -1,11 +1,13 @@
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 = {
8
9
  ApiDocsGenerator,
10
+ Authorizer,
9
11
  ...Errors,
10
12
  VidaServer,
11
13
  VidaServerController,
@@ -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.5.0",
3
+ "version": "3.0.1",
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});
@@ -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