@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.
- package/README.md +1 -0
- package/index.js +4 -0
- package/lib/activeRecord/baseRecord.js +2 -2
- package/lib/search/README.md +232 -0
- package/lib/search/abstractSearchClient.js +188 -0
- package/lib/search/errors.js +31 -0
- package/lib/search/index.js +16 -0
- package/lib/search/openSearchClient.js +319 -0
- package/lib/search/query.js +163 -0
- package/lib/server/authorizer.js +42 -0
- package/lib/server/index.js +4 -2
- package/lib/server/serverController.js +37 -3
- package/package.json +2 -1
- package/test/activeRecord/baseRecord.test.js +22 -0
- package/test/activeRecord/db/schema.test.js +15 -0
- package/test/search/abstractSearchClient.test.js +354 -0
- package/test/search/errors.test.js +41 -0
- package/test/search/helpers/searchClient.js +137 -0
- package/test/search/openSearchClient.test.js +514 -0
- package/test/search/query.test.js +286 -0
- package/test/search/serializer.test.js +202 -0
- package/test/server/authorizer.test.js +105 -0
- package/test/server/helpers/authorizer.js +19 -0
- package/test/server/serverController.test.js +77 -0
|
@@ -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
|
+
}
|
package/lib/server/index.js
CHANGED
|
@@ -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
|
|
2
|
-
const {
|
|
3
|
-
const
|
|
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": "
|
|
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
|
|