@63klabs/cache-data 1.2.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.
- package/CHANGELOG.md +234 -0
- package/LICENSE.txt +21 -0
- package/README.md +1265 -0
- package/SECURITY.md +5 -0
- package/package.json +58 -0
- package/src/index.js +9 -0
- package/src/lib/dao-cache.js +2024 -0
- package/src/lib/dao-endpoint.js +186 -0
- package/src/lib/tools/APIRequest.class.js +673 -0
- package/src/lib/tools/AWS.classes.js +250 -0
- package/src/lib/tools/CachedParametersSecrets.classes.js +492 -0
- package/src/lib/tools/ClientRequest.class.js +567 -0
- package/src/lib/tools/Connections.classes.js +466 -0
- package/src/lib/tools/DebugAndLog.class.js +416 -0
- package/src/lib/tools/ImmutableObject.class.js +71 -0
- package/src/lib/tools/RequestInfo.class.js +323 -0
- package/src/lib/tools/Response.class.js +547 -0
- package/src/lib/tools/ResponseDataModel.class.js +183 -0
- package/src/lib/tools/Timer.class.js +189 -0
- package/src/lib/tools/generic.response.html.js +88 -0
- package/src/lib/tools/generic.response.json.js +102 -0
- package/src/lib/tools/generic.response.rss.js +88 -0
- package/src/lib/tools/generic.response.text.js +86 -0
- package/src/lib/tools/generic.response.xml.js +82 -0
- package/src/lib/tools/index.js +318 -0
- package/src/lib/tools/utils.js +305 -0
- package/src/lib/tools/vars.js +34 -0
|
@@ -0,0 +1,2024 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* Classes for caching application data. Uses S3 and DynamoDb.
|
|
4
|
+
* -----------------------------------------------------------------------------
|
|
5
|
+
*
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* -----------------------------------------------------------------------------
|
|
11
|
+
* Object definitions
|
|
12
|
+
* -----------------------------------------------------------------------------
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef CacheDataFormat
|
|
17
|
+
* @property {Object} cache
|
|
18
|
+
* @property {string} cache.body
|
|
19
|
+
* @property {Object} cache.headers
|
|
20
|
+
* @property {number} cache.expires
|
|
21
|
+
* @property {string} cache.statusCode
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* -----------------------------------------------------------------------------
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const tools = require("./tools/index.js");
|
|
29
|
+
|
|
30
|
+
/* for hashing and encrypting */
|
|
31
|
+
const crypto = require("crypto"); // included by aws so don't need to add to package.json
|
|
32
|
+
const objHash = require('object-hash');
|
|
33
|
+
const moment = require('moment-timezone');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Basic S3 read/write for cache data. No cache logic,
|
|
37
|
+
* only handles the storage format and retrieval.
|
|
38
|
+
* Logic is handled by CacheData.
|
|
39
|
+
*/
|
|
40
|
+
class S3Cache {
|
|
41
|
+
|
|
42
|
+
static #bucket = null;
|
|
43
|
+
static #objPath = "cache/";
|
|
44
|
+
|
|
45
|
+
constructor () {
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
*
|
|
50
|
+
* @returns {string} The bucket name used for cached data
|
|
51
|
+
*/
|
|
52
|
+
static getBucket() {
|
|
53
|
+
return this.#bucket;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
*
|
|
58
|
+
* @returns {string} The object key (path) for cache objects
|
|
59
|
+
*/
|
|
60
|
+
static getPath() {
|
|
61
|
+
return this.#objPath;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Initialize the S3 bucket for storing cached data.
|
|
66
|
+
* @param {string} bucket The bucket name for storing cached data
|
|
67
|
+
*/
|
|
68
|
+
static init(bucket) {
|
|
69
|
+
if ( S3Cache.getBucket() === null) {
|
|
70
|
+
this.#bucket = bucket;
|
|
71
|
+
} else {
|
|
72
|
+
tools.DebugAndLog.error("S3Cache already initialized. Ignoring call to S3Cache.init()");
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* S3Cache information
|
|
78
|
+
* @returns {{bucket: string, path: string}} The bucket and path (key) used for cached data
|
|
79
|
+
*/
|
|
80
|
+
static info() {
|
|
81
|
+
return {
|
|
82
|
+
bucket: S3Cache.getBucket(),
|
|
83
|
+
path: S3Cache.getPath()
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {Buffer|ReadableStream} s3Body
|
|
89
|
+
* @returns {object} a parsed JSON object
|
|
90
|
+
*/
|
|
91
|
+
static async s3BodyToObject(s3Body) {
|
|
92
|
+
let str = "";
|
|
93
|
+
let obj = {};
|
|
94
|
+
|
|
95
|
+
// check if s3Body is buffer or stream
|
|
96
|
+
if (s3Body instanceof Buffer) {
|
|
97
|
+
str = s3Body.toString('utf-8');
|
|
98
|
+
} else {
|
|
99
|
+
str = await s3Body.transformToString();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
obj = JSON.parse(str); // TODO: if it is a stream, there are better JSON parse options
|
|
103
|
+
|
|
104
|
+
return obj;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read cache data from S3 for given idHash
|
|
109
|
+
* @param {string} idHash The id of the cached content to retrieve
|
|
110
|
+
* @returns {Promise<object>} Cache data
|
|
111
|
+
*/
|
|
112
|
+
static async read (idHash) {
|
|
113
|
+
|
|
114
|
+
return new Promise(async (resolve, reject) => {
|
|
115
|
+
|
|
116
|
+
const objKey = `${S3Cache.getPath()}${idHash}.json`;
|
|
117
|
+
const objFullLocation = `${S3Cache.getBucket()}/${objKey}`;
|
|
118
|
+
tools.DebugAndLog.debug(`Getting object from S3: ${objFullLocation}`);
|
|
119
|
+
|
|
120
|
+
let item = null;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
|
|
124
|
+
let params = {
|
|
125
|
+
Bucket: S3Cache.getBucket(),
|
|
126
|
+
Key: objKey,
|
|
127
|
+
ResponseContentType:'application/json'
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const result = await tools.AWS.s3.get(params);
|
|
131
|
+
|
|
132
|
+
tools.DebugAndLog.debug(`Success getting object from S3 ${objFullLocation}`);
|
|
133
|
+
|
|
134
|
+
item = await S3Cache.s3BodyToObject(result.Body);
|
|
135
|
+
|
|
136
|
+
tools.DebugAndLog.debug(`Success parsing object from S3 ${objFullLocation}`);
|
|
137
|
+
|
|
138
|
+
resolve(item);
|
|
139
|
+
|
|
140
|
+
} catch (error) {
|
|
141
|
+
tools.DebugAndLog.error(`Error getting object from S3 (${objFullLocation}): ${error.message}`, error.stack);
|
|
142
|
+
reject(item);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Write data to cache in S3
|
|
151
|
+
* @param {string} idHash ID of data to write
|
|
152
|
+
* @param {Object} data Data to write to cache
|
|
153
|
+
* @returns {Promise<boolean>} Whether or not the write was successful
|
|
154
|
+
*/
|
|
155
|
+
static async write (idHash, data) {
|
|
156
|
+
|
|
157
|
+
const objKey = `${S3Cache.getPath()}${idHash}.json`;
|
|
158
|
+
const objFullLocation = `${S3Cache.getBucket()}/${objKey}`;
|
|
159
|
+
tools.DebugAndLog.debug(`Putting object to S3: ${objFullLocation}`);
|
|
160
|
+
|
|
161
|
+
return new Promise( async (resolve, reject) => {
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const params = {
|
|
165
|
+
Bucket: S3Cache.getBucket(),
|
|
166
|
+
Key: objKey,
|
|
167
|
+
Body: data,
|
|
168
|
+
ContentType: 'application/json'
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
let response = await tools.AWS.s3.put(params);
|
|
172
|
+
|
|
173
|
+
tools.DebugAndLog.debug(`Put object to S3 ${objFullLocation}`, response);
|
|
174
|
+
|
|
175
|
+
resolve(true);
|
|
176
|
+
|
|
177
|
+
} catch (error) {
|
|
178
|
+
tools.DebugAndLog.error(`Error putting object to S3. [E2] (${objFullLocation}) ${error.message}`, error.stack);
|
|
179
|
+
reject(false)
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Basic DynamoDb read/write for cache data. No cache logic,
|
|
188
|
+
* only handles the storage format and retrieval.
|
|
189
|
+
* Logic is handled by CacheData.
|
|
190
|
+
*/
|
|
191
|
+
class DynamoDbCache {
|
|
192
|
+
|
|
193
|
+
static #table = null;
|
|
194
|
+
|
|
195
|
+
constructor () {
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Initialize the DynamoDb settings for storing cached data
|
|
200
|
+
* @param {string} table The table name to store cached data
|
|
201
|
+
*/
|
|
202
|
+
static init(table) {
|
|
203
|
+
if ( this.#table === null ) {
|
|
204
|
+
this.#table = table;
|
|
205
|
+
} else {
|
|
206
|
+
tools.DebugAndLog.error("DynamoDbCache already initialized. Ignoring call to DynamoDbCache.init()");
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Information about the DynamoDb table storing cached data
|
|
212
|
+
* @returns {string} The DynamoDb table name
|
|
213
|
+
*/
|
|
214
|
+
static info() {
|
|
215
|
+
return this.#table;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Read cache data from DynamoDb for given idHash
|
|
220
|
+
* @param {string} idHash The id of the cached content to retrieve
|
|
221
|
+
* @returns {Promise<object>} Cached data
|
|
222
|
+
*/
|
|
223
|
+
static async read (idHash) {
|
|
224
|
+
|
|
225
|
+
return new Promise(async (resolve, reject) => {
|
|
226
|
+
|
|
227
|
+
tools.DebugAndLog.debug(`Getting record from DynamoDb for id_hash: ${idHash}`)
|
|
228
|
+
let result = {};
|
|
229
|
+
|
|
230
|
+
// https://www.fernandomc.com/posts/eight-examples-of-fetching-data-from-dynamodb-with-node/
|
|
231
|
+
try {
|
|
232
|
+
let params = {
|
|
233
|
+
TableName: this.#table,
|
|
234
|
+
Key: {
|
|
235
|
+
"id_hash": idHash
|
|
236
|
+
},
|
|
237
|
+
ExpressionAttributeNames: {
|
|
238
|
+
"#expires": "expires",
|
|
239
|
+
"#data": "data"
|
|
240
|
+
},
|
|
241
|
+
ProjectionExpression: "id_hash, #data, #expires"
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
result = await tools.AWS.dynamo.get(params);
|
|
245
|
+
|
|
246
|
+
tools.DebugAndLog.debug(`Query success from DynamoDb for id_hash: ${idHash}`);
|
|
247
|
+
|
|
248
|
+
resolve(result);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
tools.DebugAndLog.error(`Unable to perform DynamoDb query. (${idHash}) ${error.message}`, error.stack);
|
|
251
|
+
reject(result);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Write data to cache in DynamoDb
|
|
260
|
+
* @param {object} item JSON object to write to DynamoDb
|
|
261
|
+
* @returns {Promise<boolean>} Whether or not the write was successful
|
|
262
|
+
*/
|
|
263
|
+
static async write (item) {
|
|
264
|
+
|
|
265
|
+
return new Promise( async (resolve, reject) => {
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
|
|
269
|
+
tools.DebugAndLog.debug(`Putting record to DynamoDb for id_hash: ${item.id_hash}`)
|
|
270
|
+
|
|
271
|
+
let params = {
|
|
272
|
+
Item: item,
|
|
273
|
+
TableName: this.#table
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
let response = await tools.AWS.dynamo.put(params);
|
|
277
|
+
|
|
278
|
+
tools.DebugAndLog.debug(`Write to DynamoDb for id_hash: ${item.id_hash}`, response);
|
|
279
|
+
|
|
280
|
+
resolve(true);
|
|
281
|
+
|
|
282
|
+
} catch (error) {
|
|
283
|
+
tools.DebugAndLog.error(`Write to DynamoDb failed for id_hash: ${item.id_hash} ${error.message}`, error.stack);
|
|
284
|
+
reject(false)
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Accesses cached data stored in DynamoDb and S3. CacheData is a static
|
|
294
|
+
* object that manages expiration calculations, accessing and storing data.
|
|
295
|
+
* This class is used by the publicly exposed class Cache
|
|
296
|
+
*/
|
|
297
|
+
class CacheData {
|
|
298
|
+
|
|
299
|
+
static PRIVATE = "private";
|
|
300
|
+
static PUBLIC = "public";
|
|
301
|
+
|
|
302
|
+
static PLAIN_ENCODING = "utf8";
|
|
303
|
+
static CRYPT_ENCODING = "hex";
|
|
304
|
+
|
|
305
|
+
static #secureDataAlgorithm = null;
|
|
306
|
+
static #secureDataKey = null;
|
|
307
|
+
static #dynamoDbMaxCacheSize_kb = 10;
|
|
308
|
+
static #purgeExpiredCacheEntriesAfterXHours = 24;
|
|
309
|
+
static #timeZoneForInterval = "UTC";
|
|
310
|
+
static #offsetInMinutes = 0;
|
|
311
|
+
|
|
312
|
+
constructor() {
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
*
|
|
317
|
+
* @param {Object} parameters
|
|
318
|
+
* @param {string} parameters.dynamoDbTable
|
|
319
|
+
* @param {string} parameters.s3Bucket
|
|
320
|
+
* @param {string} parameters.secureDataAlgorithm
|
|
321
|
+
* @param {string|Buffer|tools.Secret|tools.CachedSSMParameter|tools.CachedSecret} parameters.secureDataKey
|
|
322
|
+
* @param {number} parameters.DynamoDbMaxCacheSize_kb
|
|
323
|
+
* @param {number} parameters.purgeExpiredCacheEntriesAfterXHours
|
|
324
|
+
* @param {string} parameters.timeZoneForInterval
|
|
325
|
+
*/
|
|
326
|
+
static init(parameters) {
|
|
327
|
+
|
|
328
|
+
// if we don't have the key set, we don't have anything set
|
|
329
|
+
if ( this.#secureDataKey === null ) {
|
|
330
|
+
|
|
331
|
+
// TODO: Throw error if data is missing
|
|
332
|
+
|
|
333
|
+
DynamoDbCache.init(parameters.dynamoDbTable);
|
|
334
|
+
S3Cache.init(parameters.s3Bucket);
|
|
335
|
+
|
|
336
|
+
// set other values
|
|
337
|
+
this.#secureDataAlgorithm = parameters.secureDataAlgorithm;
|
|
338
|
+
this.#secureDataKey = parameters.secureDataKey;
|
|
339
|
+
|
|
340
|
+
if ("DynamoDbMaxCacheSize_kb" in parameters ) { this.#dynamoDbMaxCacheSize_kb = parameters.DynamoDbMaxCacheSize_kb; }
|
|
341
|
+
if ("purgeExpiredCacheEntriesAfterXHours" in parameters ) { this.#purgeExpiredCacheEntriesAfterXHours = parameters.purgeExpiredCacheEntriesAfterXHours; }
|
|
342
|
+
if ("timeZoneForInterval" in parameters ) { this.#timeZoneForInterval = parameters.timeZoneForInterval; }
|
|
343
|
+
|
|
344
|
+
this._setOffsetInMinutes();
|
|
345
|
+
|
|
346
|
+
} else {
|
|
347
|
+
tools.DebugAndLog.warn("CacheData already initialized. Ignoring call to CacheData.init()");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Similar to init, but runs during execution time to refresh environment variables that may have changed since init.
|
|
354
|
+
* Calling .prime() without an await can help get runtime refreshes started.
|
|
355
|
+
* You can safely call .prime() again with an await to make sure it has completed just before you need it.
|
|
356
|
+
* @returns {Promise<boolean>}
|
|
357
|
+
*/
|
|
358
|
+
static async prime() {
|
|
359
|
+
return new Promise(async (resolve, reject) => {
|
|
360
|
+
try {
|
|
361
|
+
let primeTasks = [];
|
|
362
|
+
|
|
363
|
+
if (CacheData.getSecureDataKeyType() === 'CachedParameterSecret') {
|
|
364
|
+
primeTasks.push( this.#secureDataKey.prime());
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
await Promise.all(primeTasks);
|
|
368
|
+
|
|
369
|
+
resolve(true);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
tools.DebugAndLog.error(`CacheData.prime() failed ${error.message}`, error.stack);
|
|
372
|
+
reject(false);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Used in the init() method, based on the timeZoneForInterval and current
|
|
379
|
+
* date, set the offset in minutes (offset from UTC taking into account
|
|
380
|
+
* daylight savings time for that time zone)
|
|
381
|
+
*/
|
|
382
|
+
static _setOffsetInMinutes() {
|
|
383
|
+
this.#offsetInMinutes = ( -1 * moment.tz.zone(this.getTimeZoneForInterval()).utcOffset(Date.now())); // invert by *-1 because of POSIX
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
*
|
|
388
|
+
* @returns {number} The offset in minutes taking into account whether or not daylight savings is in effect AND observed
|
|
389
|
+
*/
|
|
390
|
+
static getOffsetInMinutes() {
|
|
391
|
+
return this.#offsetInMinutes;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
|
396
|
+
* @returns {string} Returns the TZ database name assigned to the time zone
|
|
397
|
+
*/
|
|
398
|
+
static getTimeZoneForInterval() {
|
|
399
|
+
return this.#timeZoneForInterval;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Get information about the cache settings
|
|
404
|
+
* @returns {{
|
|
405
|
+
* dynamoDbTable: string,
|
|
406
|
+
* s3Bucket: string,
|
|
407
|
+
* secureDataAlgorithm: string,
|
|
408
|
+
* secureDataKey: string,
|
|
409
|
+
* DynamoDbMaxCacheSize_kb: number,
|
|
410
|
+
* purgeExpiredCacheEntriesAfterXHours: number,
|
|
411
|
+
* timeZoneForInterval: string,
|
|
412
|
+
* offsetInMinutes: number
|
|
413
|
+
* }}
|
|
414
|
+
*/
|
|
415
|
+
static info() {
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
dynamoDbTable: DynamoDbCache.info(),
|
|
419
|
+
s3Bucket: S3Cache.info(),
|
|
420
|
+
secureDataAlgorithm: this.#secureDataAlgorithm,
|
|
421
|
+
secureDataKey: `************** [${CacheData.getSecureDataKeyType()}]`,
|
|
422
|
+
DynamoDbMaxCacheSize_kb: this.#dynamoDbMaxCacheSize_kb,
|
|
423
|
+
purgeExpiredCacheEntriesAfterXHours: this.#purgeExpiredCacheEntriesAfterXHours,
|
|
424
|
+
timeZoneForInterval: CacheData.getTimeZoneForInterval(),
|
|
425
|
+
offsetInMinutes: CacheData.getOffsetInMinutes()
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Format the cache object for returning to main program
|
|
432
|
+
* @param {number} expires
|
|
433
|
+
* @param {Object} body
|
|
434
|
+
* @param {Object} headers
|
|
435
|
+
* @param {string} statusCode
|
|
436
|
+
* @returns {CacheDataFormat} Formatted cache object
|
|
437
|
+
*/
|
|
438
|
+
static format(expires = null, body = null, headers = null, statusCode = null) {
|
|
439
|
+
return { "cache": { body: body, headers: headers, expires: expires, statusCode: statusCode } };
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
*
|
|
444
|
+
* @param {string} idHash
|
|
445
|
+
* @param {Object} item
|
|
446
|
+
* @param {number} syncedNow
|
|
447
|
+
* @param {number} syncedLater
|
|
448
|
+
* @returns {Promise<{ body: string, headers: Object, expires: number, statusCode: string }>}
|
|
449
|
+
*/
|
|
450
|
+
static async _process(idHash, item, syncedNow, syncedLater) {
|
|
451
|
+
|
|
452
|
+
return new Promise(async (resolve, reject) => {
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
|
|
456
|
+
// Is this a pointer to data in S3?
|
|
457
|
+
if ("data" in item && "info" in item.data && "objInS3" in item.data.info && item.data.info.objInS3 === true) {
|
|
458
|
+
tools.DebugAndLog.debug(`Item is in S3. Fetching... (${idHash})`);
|
|
459
|
+
item = await S3Cache.read(idHash); // The data is stored in S3 so get it
|
|
460
|
+
tools.DebugAndLog.debug(`Item returned from S3 replaces pointer to S3 (${idHash})`, item);
|
|
461
|
+
// NOTE: if this fails and returns null it will be handled as any item === null which is to say that body will be null
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
let body = null;
|
|
465
|
+
let headers = null;
|
|
466
|
+
let expires = syncedLater;
|
|
467
|
+
let statusCode = null;
|
|
468
|
+
|
|
469
|
+
if (item !== null) {
|
|
470
|
+
tools.DebugAndLog.debug(`Process data from cache (${idHash})`);
|
|
471
|
+
body = item.data.body; // set the cached body data (this is what we will be the body of the response)
|
|
472
|
+
|
|
473
|
+
headers = item.data.headers;
|
|
474
|
+
expires = item.expires;
|
|
475
|
+
statusCode = item.data.statusCode;
|
|
476
|
+
|
|
477
|
+
// if the body is encrypted (because classification is private) decrypt it
|
|
478
|
+
if ( item.data.info.classification === CacheData.PRIVATE ) {
|
|
479
|
+
try {
|
|
480
|
+
tools.DebugAndLog.debug(`Policy for (${idHash}) data is classified as PRIVATE. Decrypting body...`);
|
|
481
|
+
await CacheData.prime();
|
|
482
|
+
body = this._decrypt(body);
|
|
483
|
+
} catch (error) {
|
|
484
|
+
// Decryption failed
|
|
485
|
+
body = null;
|
|
486
|
+
expires = syncedNow;
|
|
487
|
+
headers = null;
|
|
488
|
+
statusCode = "500";
|
|
489
|
+
tools.DebugAndLog.error(`Unable to decrypt cache. Ignoring it. (${idHash}) ${error.message}`, error.stack);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
resolve({ body: body, headers: headers, expires: expires, statusCode: statusCode });
|
|
495
|
+
} catch (error) {
|
|
496
|
+
tools.DebugAndLog.error(`Error getting data from cache. (${idHash}) ${error.message}`, error.stack);
|
|
497
|
+
reject( {body: null, expires: syncedNow, headers: null, statusCode: "500"} );
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
});
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
*
|
|
505
|
+
* @param {string} idHash
|
|
506
|
+
* @param {number} syncedLater
|
|
507
|
+
* @returns {Promise<CacheDataFormat>}
|
|
508
|
+
*/
|
|
509
|
+
static async read(idHash, syncedLater) {
|
|
510
|
+
|
|
511
|
+
return new Promise(async (resolve, reject) => {
|
|
512
|
+
|
|
513
|
+
let cache = this.format(syncedLater);
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
|
|
517
|
+
const result = await DynamoDbCache.read(idHash);
|
|
518
|
+
|
|
519
|
+
/* if we have a cached object, provide it for evaluation */
|
|
520
|
+
/* NOTE: AWS-SDK seems to provide a hidden Item that is undefined? toString and stringify doesn't show it,
|
|
521
|
+
but "Item" in result will be true. So we will do extensive testing to make compatible with both v2 and v3 */
|
|
522
|
+
if ( "Item" in result && typeof result.Item !== "undefined" && result.Item !== null ) {
|
|
523
|
+
// hand the item over for processing
|
|
524
|
+
const cachedCopy = await this._process(idHash, result.Item);
|
|
525
|
+
cache = this.format(cachedCopy.expires, cachedCopy.body, cachedCopy.headers, cachedCopy.statusCode);
|
|
526
|
+
tools.DebugAndLog.debug(`Cached Item Processed: ${idHash}`);
|
|
527
|
+
} else {
|
|
528
|
+
tools.DebugAndLog.debug(`No cache found for ${idHash}`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
resolve(cache);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
tools.DebugAndLog.error(`CacheData.read(${idHash}) failed ${error.message}`, error.stack);
|
|
534
|
+
reject(cache);
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
});
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
*
|
|
542
|
+
* @param {string} idHash ID of data to write
|
|
543
|
+
* @param {number} syncedNow
|
|
544
|
+
* @param {string} body
|
|
545
|
+
* @param {Object} headers
|
|
546
|
+
* @param {string} host
|
|
547
|
+
* @param {string} path
|
|
548
|
+
* @param {number} expires
|
|
549
|
+
* @param {number} statusCode
|
|
550
|
+
* @param {boolean} encrypt
|
|
551
|
+
* @returns {CacheDataFormat}
|
|
552
|
+
*/
|
|
553
|
+
static write (idHash, syncedNow, body, headers, host, path, expires, statusCode, encrypt = true) {
|
|
554
|
+
|
|
555
|
+
let cacheData = null;
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
|
|
559
|
+
tools.DebugAndLog.debug(`Updating Cache for ${idHash} now:${syncedNow} | host:${host} | path:${path} | expires:${expires} | statusCode:${statusCode} | encrypt:${encrypt} ... `);
|
|
560
|
+
|
|
561
|
+
if( isNaN(expires) || expires < syncedNow ) {
|
|
562
|
+
expires = syncedNow + 300;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// lowercase all headers
|
|
566
|
+
headers = CacheData.lowerCaseKeys(headers);
|
|
567
|
+
|
|
568
|
+
// set etag
|
|
569
|
+
if ( !("etag" in headers) ) {
|
|
570
|
+
headers.etag = CacheData.generateEtag(idHash, body);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// set last modified
|
|
574
|
+
if ( !("last-modified" in headers) ) {
|
|
575
|
+
headers['last-modified'] = CacheData.generateInternetFormattedDate(syncedNow);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// set expires in header
|
|
579
|
+
if ( !("expires" in headers) ) {
|
|
580
|
+
headers['expires'] = CacheData.generateInternetFormattedDate(expires);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
cacheData = CacheData.format(expires, body, headers, statusCode);
|
|
584
|
+
|
|
585
|
+
const bodySize_kb = this.calculateKBytes(body);
|
|
586
|
+
let bodyToStore = body;
|
|
587
|
+
|
|
588
|
+
// if the endpoint policy is classified as private, encrypt
|
|
589
|
+
if ( encrypt ) {
|
|
590
|
+
tools.DebugAndLog.debug(`Policy for (${idHash}) data is classified as PRIVATE. Encrypting body...`);
|
|
591
|
+
bodyToStore = this._encrypt(body);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// create the (preliminary) cache record
|
|
595
|
+
let item = {
|
|
596
|
+
id_hash: idHash,
|
|
597
|
+
expires: expires,
|
|
598
|
+
purge_ts: (syncedNow + (this.#purgeExpiredCacheEntriesAfterXHours * 3600)),
|
|
599
|
+
data: {
|
|
600
|
+
info: {
|
|
601
|
+
expires: headers.expires,
|
|
602
|
+
host: host,
|
|
603
|
+
path: path,
|
|
604
|
+
classification: (encrypt ? CacheData.PRIVATE : CacheData.PUBLIC),
|
|
605
|
+
size_kb: bodySize_kb,
|
|
606
|
+
objInS3: false
|
|
607
|
+
},
|
|
608
|
+
headers: headers,
|
|
609
|
+
body: bodyToStore,
|
|
610
|
+
statusCode: statusCode
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
/*
|
|
615
|
+
DynamoDb has a limit of 400KB per item so we want to make sure
|
|
616
|
+
the Item does not take up that much space. Also, we want
|
|
617
|
+
DynamoDb to run efficiently so it is best to only store smaller
|
|
618
|
+
items there and move larger items into S3.
|
|
619
|
+
|
|
620
|
+
Any items larger than the max size we set will be stored over
|
|
621
|
+
in S3.
|
|
622
|
+
|
|
623
|
+
What is the max size? It can be set in the Lambda Environment
|
|
624
|
+
Variables and discovering the proper balance will take some trials.
|
|
625
|
+
We don't want to constantly be calling S3, but we also don't want
|
|
626
|
+
to make DynamoDb too heavy either.
|
|
627
|
+
|
|
628
|
+
(In summary: Max Item size in DynamoDb is 400KB, and storing too many large
|
|
629
|
+
items can have a performance impact. However constantly calling
|
|
630
|
+
S3 also will have a performance impact.)
|
|
631
|
+
*/
|
|
632
|
+
|
|
633
|
+
// do the size check
|
|
634
|
+
if (bodySize_kb > this.#dynamoDbMaxCacheSize_kb) {
|
|
635
|
+
// over max size limit set in Lambda Environment Variables
|
|
636
|
+
S3Cache.write(idHash, JSON.stringify(item) );
|
|
637
|
+
// update the Item we will pass to DynamoDb
|
|
638
|
+
let preview = (typeof item.data.body === "string") ? item.data.body.slice(0,100)+"..." : "[---ENCRYPTED---]";
|
|
639
|
+
item.data.body = "ID: "+idHash+" PREVIEW: "+preview;
|
|
640
|
+
item.data.info.objInS3 = true;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
DynamoDbCache.write(item); // we don't wait for a response
|
|
644
|
+
|
|
645
|
+
} catch (error) {
|
|
646
|
+
tools.DebugAndLog.error(`CacheData.write for ${idHash} FAILED now:${syncedNow} | host:${host} | path:${path} | expires:${expires} | statusCode:${statusCode} | encrypt:${encrypt} failed. ${error.message}`, error.stack);
|
|
647
|
+
cacheData = CacheData.format(0);
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
return cacheData;
|
|
651
|
+
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
/*
|
|
655
|
+
***********************************************************************
|
|
656
|
+
Encryption Functions
|
|
657
|
+
-----------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
Encrypt and Decrypt data classified as private
|
|
660
|
+
|
|
661
|
+
Even though we can set up DynamoDB to encrypt data when at rest, we
|
|
662
|
+
don't want data classified as private to be viewed or exported from the
|
|
663
|
+
AWS console
|
|
664
|
+
|
|
665
|
+
-----------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
Adapted from
|
|
668
|
+
https://codeforgeek.com/encrypt-and-decrypt-data-in-node-js/
|
|
669
|
+
|
|
670
|
+
and
|
|
671
|
+
https://nodejs.org/api/crypto.html
|
|
672
|
+
|
|
673
|
+
************************************************************************
|
|
674
|
+
*/
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Returns the type of secureDataKey
|
|
678
|
+
*
|
|
679
|
+
* @returns {string} 'buffer', 'string', 'CachedParameterSecret'
|
|
680
|
+
*/
|
|
681
|
+
static getSecureDataKeyType() {
|
|
682
|
+
// look at type of parameters.secureDataKey as it can be a string, Buffer, or object.
|
|
683
|
+
let dataKeyType = typeof this.#secureDataKey;
|
|
684
|
+
if ( Buffer.isBuffer(this.#secureDataKey)) { dataKeyType = 'buffer'; }
|
|
685
|
+
if ( dataKeyType === 'object' && this.#secureDataKey instanceof tools.CachedParameterSecret ) { dataKeyType = 'CachedParameterSecret'; }
|
|
686
|
+
return dataKeyType;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Obtain the secureDataKey as a Buffer for encryption/decryption.
|
|
691
|
+
*
|
|
692
|
+
* @returns {Buffer|null} The Data key as a buffer in the format specified by CacheData.CRYPT_ENCODING
|
|
693
|
+
*/
|
|
694
|
+
static getSecureDataKey() {
|
|
695
|
+
|
|
696
|
+
let buff = null;
|
|
697
|
+
|
|
698
|
+
try {
|
|
699
|
+
|
|
700
|
+
// The secureDataKey can be stored several different ways
|
|
701
|
+
switch (CacheData.getSecureDataKeyType()) {
|
|
702
|
+
case 'buffer':
|
|
703
|
+
buff = this.#secureDataKey;
|
|
704
|
+
break;
|
|
705
|
+
case 'string':
|
|
706
|
+
buff = Buffer.from(this.#secureDataKey, this.CRYPT_ENCODING);
|
|
707
|
+
break;
|
|
708
|
+
case 'CachedParameterSecret':
|
|
709
|
+
// it may be null
|
|
710
|
+
const key = this.#secureDataKey.sync_getValue();
|
|
711
|
+
buff = ( key === null ) ? null : Buffer.from( key, this.CRYPT_ENCODING);
|
|
712
|
+
break;
|
|
713
|
+
default:
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
} catch (error) {
|
|
718
|
+
tools.DebugAndLog.error(`CacheData.getSecureDataKey() failed ${error.message}`, error.stack);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return buff;
|
|
722
|
+
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
*
|
|
727
|
+
* @param {string} text Data to encrypt
|
|
728
|
+
* @returns {string} Encrypted data
|
|
729
|
+
*/
|
|
730
|
+
static _encrypt (text) {
|
|
731
|
+
|
|
732
|
+
const dataKey = this.getSecureDataKey();
|
|
733
|
+
|
|
734
|
+
tools.DebugAndLog.debug(`Encrypting cache using ${this.#secureDataAlgorithm} with secureDataKey [${CacheData.getSecureDataKeyType()}] ... `);
|
|
735
|
+
|
|
736
|
+
// can't encrypt null, so we'll substitute (and in _decrypt() reverse the sub)
|
|
737
|
+
if (text === null) { text = "{{{null}}}"; }
|
|
738
|
+
|
|
739
|
+
let iv = crypto.randomBytes(16);
|
|
740
|
+
let cipher = crypto.createCipheriv(this.#secureDataAlgorithm, Buffer.from(dataKey), iv);
|
|
741
|
+
|
|
742
|
+
let encrypted = cipher.update(text, this.PLAIN_ENCODING, this.CRYPT_ENCODING);
|
|
743
|
+
encrypted += cipher.final(this.CRYPT_ENCODING);
|
|
744
|
+
|
|
745
|
+
return { iv: iv.toString(this.CRYPT_ENCODING), encryptedData: encrypted };
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
*
|
|
750
|
+
* @param {string} data Data to decrypt
|
|
751
|
+
* @returns {string} Decrypted data
|
|
752
|
+
*/
|
|
753
|
+
static _decrypt (data) {
|
|
754
|
+
|
|
755
|
+
const dataKey = this.getSecureDataKey();
|
|
756
|
+
|
|
757
|
+
tools.DebugAndLog.debug(`Decrypting cache using ${this.#secureDataAlgorithm} with secureDataKey [${CacheData.getSecureDataKeyType()}] ... `);
|
|
758
|
+
|
|
759
|
+
let plainEncoding = ("plainEncoding" in data) ? data.plainEncoding : this.PLAIN_ENCODING;
|
|
760
|
+
let cryptEncoding = ("cryptEncoding" in data) ? data.cryptEncoding : this.CRYPT_ENCODING;
|
|
761
|
+
|
|
762
|
+
let iv = Buffer.from(data.iv, cryptEncoding);
|
|
763
|
+
let decipher = crypto.createDecipheriv(this.#secureDataAlgorithm, Buffer.from(dataKey), iv);
|
|
764
|
+
|
|
765
|
+
let decrypted = decipher.update(data.encryptedData, cryptEncoding, plainEncoding);
|
|
766
|
+
decrypted += decipher.final(plainEncoding);
|
|
767
|
+
|
|
768
|
+
// reverse the substitute for null that _encrypt() used
|
|
769
|
+
if ( decrypted === "{{{null}}}") { decrypted = null; }
|
|
770
|
+
|
|
771
|
+
return decrypted;
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
// utility functions
|
|
775
|
+
/**
|
|
776
|
+
* Generate an eTag hash
|
|
777
|
+
*
|
|
778
|
+
* This is very basic as there is no specification for doing this.
|
|
779
|
+
* All an eTag needs to be is a unique hash for a particular request.
|
|
780
|
+
* We already have a unique ID for the request, so it's not like we
|
|
781
|
+
* need to make sure the content matches (or does not match) any content
|
|
782
|
+
* throughout the rest of the world. We are just doing a quick check
|
|
783
|
+
* at this exact endpoint. So we pair the idHash (this exact endpoint)
|
|
784
|
+
* with it's content.
|
|
785
|
+
* @param {string} idHash The id of the endpoint
|
|
786
|
+
* @param {string} content The string to generate an eTag for
|
|
787
|
+
* @returns {string} 10 character ETag hash
|
|
788
|
+
*/
|
|
789
|
+
static generateEtag (idHash, content) {
|
|
790
|
+
const hasher = crypto.createHash('sha1');
|
|
791
|
+
hasher.update(idHash+content);
|
|
792
|
+
return hasher.digest('hex').slice(0, 10); // we'll only take 10 characters
|
|
793
|
+
// again, we aren't comparing the hash to the rest of the world
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Generate an internet formatted date such as those used in headers.
|
|
798
|
+
*
|
|
799
|
+
* Example: "Wed, 28 Jul 2021 12:24:11 GMT"
|
|
800
|
+
*
|
|
801
|
+
* The timestamp passed is expected to be in seconds. If it is in
|
|
802
|
+
* milliseconds then inMilliSeconds parameter needs to be set to
|
|
803
|
+
* true.
|
|
804
|
+
* @param {number} timestamp If in milliseconds, inMilliseconds parameter MUST be set to true
|
|
805
|
+
* @param {boolean} inMilliSeconds Set to true if timestamp passed is in milliseconds. Default is false
|
|
806
|
+
* @returns {string} An internet formatted date such as Wed, 28 Jul 2021 12:24:11 GMT
|
|
807
|
+
*/
|
|
808
|
+
static generateInternetFormattedDate (timestamp, inMilliSeconds = false) {
|
|
809
|
+
|
|
810
|
+
if ( !inMilliSeconds ) {
|
|
811
|
+
timestamp = CacheData.convertTimestampFromSecondsToMilli(timestamp);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return new Date(timestamp).toUTCString();
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Returns an object with lowercase keys. Note that if after
|
|
819
|
+
* lowercasing the keys there is a collision one will be
|
|
820
|
+
* over-written.
|
|
821
|
+
* Can be used for headers, response, or more.
|
|
822
|
+
* @param {Object} objectWithKeys
|
|
823
|
+
* @returns {Object} Same object but with lowercase keys
|
|
824
|
+
*/
|
|
825
|
+
static lowerCaseKeys (objectWithKeys) {
|
|
826
|
+
let objectWithLowerCaseKeys = {};
|
|
827
|
+
if ( objectWithKeys !== null ) {
|
|
828
|
+
let keys = Object.keys(objectWithKeys);
|
|
829
|
+
// move each value from objectWithKeys to objectWithLowerCaseKeys
|
|
830
|
+
keys.forEach( function( k ) {
|
|
831
|
+
objectWithLowerCaseKeys[k.toLowerCase()] = objectWithKeys[k];
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
return objectWithLowerCaseKeys;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Calculate the number of Kilobytes in memory a String takes up.
|
|
839
|
+
* This function first calculates the number of bytes in the String using
|
|
840
|
+
* Buffer.byteLength() and then converts it to KB = (bytes / 1024)
|
|
841
|
+
* @param {string} aString A string to calculate on
|
|
842
|
+
* @param {string} encode What character encoding should be used? Default is "utf8"
|
|
843
|
+
* @returns {number} String size in estimated KB
|
|
844
|
+
*/
|
|
845
|
+
static calculateKBytes ( aString, encode = CacheData.PLAIN_ENCODING ) {
|
|
846
|
+
let kbytes = 0;
|
|
847
|
+
|
|
848
|
+
if ( aString !== null ) {
|
|
849
|
+
//https://www.jacklmoore.com/notes/rounding-in-javascript/
|
|
850
|
+
|
|
851
|
+
kbytes = Number(Math.round((Buffer.byteLength(aString, encode) / 1024)+'e3')+'e-3'); ; // size in KB (rounded to 3 decimals)
|
|
852
|
+
// 3 decimals is good as 1 byte = .0009KB (rounded to .001) and 5bytes = .0048KB (rounded to .005)
|
|
853
|
+
// Otherwise .0009KB would be rounded to .00 and .0048 rounded to .00
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
return kbytes;
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* We can set times and expirations on intervals, such as every
|
|
861
|
+
* 15 seconds (mm:00, mm:15, mm:30, mm:45), every half hour
|
|
862
|
+
* (hh:00:00, hh:30:00), every hour (T00:00:00, T01:00:00), etc.
|
|
863
|
+
* In some cases such as every 2 hours, the interval is calculated
|
|
864
|
+
* from midnight in the timezone specified in timeZoneForInterval
|
|
865
|
+
* Spans of days (such as every two days (48 hours) or every three
|
|
866
|
+
* days (72 hours) are calculated from midnight of the UNIX epoch
|
|
867
|
+
* (January 1, 1970).
|
|
868
|
+
*
|
|
869
|
+
* When a timezone is set in timeZoneForInterval, then there is
|
|
870
|
+
* a slight adjustment made so that the interval lines up with
|
|
871
|
+
* midnight of the "local" time. For example, if an organization
|
|
872
|
+
* is primarily located in the Central Time Zone (or their
|
|
873
|
+
* nightly batch jobs occur at GMT-05:00) then timeZoneForInterval
|
|
874
|
+
* may be set to "America/Chicago" so that midnight in
|
|
875
|
+
* "America/Chicago" may be used for calculations. That keeps
|
|
876
|
+
* every 4 hours on hours 00, 04, 08, 12, 16, etc.
|
|
877
|
+
* @param {number} intervalInSeconds
|
|
878
|
+
* @param {number} timestampInSeconds
|
|
879
|
+
* @returns {number} Next interval in seconds
|
|
880
|
+
*/
|
|
881
|
+
static nextIntervalInSeconds(intervalInSeconds, timestampInSeconds = 0 ) {
|
|
882
|
+
|
|
883
|
+
// if no timestamp given, the default timestamp is now()
|
|
884
|
+
if ( timestampInSeconds === 0 ) {
|
|
885
|
+
timestampInSeconds = CacheData.convertTimestampFromMilliToSeconds(Date.now());
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/* We do an offset conversion by adjusting the timestamp to a "local"
|
|
889
|
+
time. This is purely for calculations and is not used as a "date".
|
|
890
|
+
*/
|
|
891
|
+
|
|
892
|
+
// Add in offset so we can calculate from midnight local time
|
|
893
|
+
let offset = (CacheData.getOffsetInMinutes() * 60 ); // convert to seconds
|
|
894
|
+
timestampInSeconds += offset;
|
|
895
|
+
|
|
896
|
+
// convert the seconds into a date
|
|
897
|
+
let date = new Date( CacheData.convertTimestampFromSecondsToMilli(timestampInSeconds) );
|
|
898
|
+
|
|
899
|
+
// https://stackoverflow.com/questions/10789384/round-a-date-to-the-nearest-5-minutes-in-javascript
|
|
900
|
+
let coeff = CacheData.convertTimestampFromSecondsToMilli(intervalInSeconds);
|
|
901
|
+
let rounded = new Date(Math.ceil(date.getTime() / coeff) * coeff);
|
|
902
|
+
let nextInSeconds = CacheData.convertTimestampFromMilliToSeconds(rounded.getTime());
|
|
903
|
+
|
|
904
|
+
// revert the offset so we are looking at UTC
|
|
905
|
+
nextInSeconds -= offset;
|
|
906
|
+
|
|
907
|
+
return nextInSeconds;
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* If no parameter is passed, Date.now() is used.
|
|
912
|
+
* @param {number} timestampInMillseconds The timestamp in milliseconds to convert to seconds
|
|
913
|
+
* @returns {number} The timestamp in seconds
|
|
914
|
+
*/
|
|
915
|
+
static convertTimestampFromMilliToSeconds (timestampInMillseconds = 0) {
|
|
916
|
+
if (timestampInMillseconds === 0) { timestampInMillseconds = Date.now().getTime(); }
|
|
917
|
+
return Math.ceil(timestampInMillseconds / 1000);
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* If no parameter is passed, Date.now() is used.
|
|
922
|
+
* @param {number} timestampInSeconds
|
|
923
|
+
* @returns {number} Timestamp in milliseconds
|
|
924
|
+
*/
|
|
925
|
+
static convertTimestampFromSecondsToMilli (timestampInSeconds = 0) {
|
|
926
|
+
let timestampInMilli = 0;
|
|
927
|
+
|
|
928
|
+
if (timestampInSeconds === 0) {
|
|
929
|
+
timestampInMilli = Date.now().getTime();
|
|
930
|
+
} else {
|
|
931
|
+
timestampInMilli = timestampInSeconds * 1000;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
return timestampInMilli;
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* The Cache object handles reads and writes from the cache.
|
|
944
|
+
* It also acts as a proxy between the app and CacheData which is a private class.
|
|
945
|
+
* This is the actual data object our application can work with and is returned
|
|
946
|
+
* from CachableDataAccess.
|
|
947
|
+
*
|
|
948
|
+
* Before using it must be initialized
|
|
949
|
+
*
|
|
950
|
+
* Cache.init({parameters});
|
|
951
|
+
*
|
|
952
|
+
* Then you can create new objects:
|
|
953
|
+
*
|
|
954
|
+
* const cacheObject = new Cache({id}, {parameters});
|
|
955
|
+
*/
|
|
956
|
+
class Cache {
|
|
957
|
+
|
|
958
|
+
static PUBLIC = CacheData.PUBLIC;
|
|
959
|
+
static PRIVATE = CacheData.PRIVATE;
|
|
960
|
+
|
|
961
|
+
static CRYPT_ENCODING = CacheData.CRYPT_ENCODING;
|
|
962
|
+
static PLAIN_ENCODING = CacheData.PLAIN_ENCODING;
|
|
963
|
+
|
|
964
|
+
static STATUS_NO_CACHE = "original";
|
|
965
|
+
static STATUS_EXPIRED = "original:cache-expired";
|
|
966
|
+
static STATUS_CACHE_SAME = "cache:original-same-as-cache";
|
|
967
|
+
static STATUS_CACHE = "cache";
|
|
968
|
+
static STATUS_CACHE_ERROR = "error:cache"
|
|
969
|
+
static STATUS_ORIGINAL_NOT_MODIFIED = "cache:original-not-modified";
|
|
970
|
+
static STATUS_ORIGINAL_ERROR = "error:original";
|
|
971
|
+
static STATUS_FORCED = "original:cache-update-forced";
|
|
972
|
+
|
|
973
|
+
static #idHashAlgorithm = null;
|
|
974
|
+
static #useToolsHash = false;
|
|
975
|
+
|
|
976
|
+
#syncedNowTimestampInSeconds = 0; // consistent time base for calculations
|
|
977
|
+
#syncedLaterTimestampInSeconds = 0; // default expiration if not adjusted
|
|
978
|
+
#idHash = "";
|
|
979
|
+
#status = null;
|
|
980
|
+
#errorCode = 0;
|
|
981
|
+
#store = null;
|
|
982
|
+
|
|
983
|
+
#overrideOriginHeaderExpiration = false;
|
|
984
|
+
#defaultExpirationInSeconds = 60;
|
|
985
|
+
#defaultExpirationExtensionOnErrorInSeconds = 3600;
|
|
986
|
+
#expirationIsOnInterval = false;
|
|
987
|
+
#headersToRetain = [];
|
|
988
|
+
|
|
989
|
+
#hostId = "notset";
|
|
990
|
+
#pathId = "notset";
|
|
991
|
+
#encrypt = true;
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Create a new Cache object
|
|
995
|
+
* @param {object} connection An object that contains data location and connection details. Typically a connection object. It may be of any format with any keys as long as they can uniquely identify this cashed object from others
|
|
996
|
+
* @param {object} cacheProfile An object with some or all of the available parameter settings listed above.
|
|
997
|
+
* @param {boolean} cacheProfile.overrideOriginHeaderExpiration Will we ignore and replace the expires header from origin or will we create our own? Defalue: false
|
|
998
|
+
* @param {number} cacheProfile.defaultExpirationInSeconds In seconds, how long is the default expiration? Default: 60 (60 seconds)
|
|
999
|
+
* @param {number} cacheProfile.defaultExpirationExtensionOnErrorInSeconds In seconds, if there is an error, how long until the error expires from cache? Default: 3600 (5 minutes)
|
|
1000
|
+
* @param {boolean} cacheProfile.expirationIsOnInterval Does the cache expires timer reset on first request, or is the expires set to the clock? (ex. every 10 seconds, every hour, etc) Default: false
|
|
1001
|
+
* @param {Array|string} cacheProfile.headersToRetain Array or comma deliminated string of header keys to keep from the original source to cache and pass to client. Note that there are certain headers such as content type that are always retained. Default: [] (none)
|
|
1002
|
+
* @param {string} cacheProfile.hostId Used for logging. Does not need to be a valid internet host. Any identifier is valid. Default: "notset"
|
|
1003
|
+
* @param {string} cacheProfile.pathId Used for logging. Does not need to be a valid internet path. Should not contain sensitive information. For example, /record/user/488322 should just be /record/user/ to denote a user record was accessed. Default: "notset"
|
|
1004
|
+
* @param {boolean} cacheProfile.encrypt When at rest is the data encrypted? This also corresponds to "public" (encrypted: false) or "private" (encrypted: true) in the cache-control header. Default: true
|
|
1005
|
+
*/
|
|
1006
|
+
constructor(connection, cacheProfile = null) {
|
|
1007
|
+
|
|
1008
|
+
// set cacheProfile first - these come from files and fields, so we need to cast them
|
|
1009
|
+
if (cacheProfile !== null) {
|
|
1010
|
+
|
|
1011
|
+
// There is some documentation and template code that uses different names for these cacheProfile - offda - sorry - chadkluck 2023-08-04
|
|
1012
|
+
// https://github.com/chadkluck/npm-chadkluck-cache-data/issues/71
|
|
1013
|
+
if ( "expiresIsOnInterval" in cacheProfile ) { this.#expirationIsOnInterval = Cache.bool(cacheProfile.expiresIsOnInterval); } // we'll accept this for backwards compatibility - chadkluck 2023-08-05
|
|
1014
|
+
if ( "expirationIsOnInterval" in cacheProfile ) { this.#expirationIsOnInterval = Cache.bool(cacheProfile.expirationIsOnInterval); }
|
|
1015
|
+
|
|
1016
|
+
if ( "defaultExpiresInSeconds" in cacheProfile ) { this.#defaultExpirationInSeconds = parseInt(cacheProfile.defaultExpiresInSeconds, 10); } // we'll accept this for backwards compatibility - chadkluck 2023-08-05
|
|
1017
|
+
if ( "defaultExpirationInSeconds" in cacheProfile ) { this.#defaultExpirationInSeconds = parseInt(cacheProfile.defaultExpirationInSeconds, 10); }
|
|
1018
|
+
|
|
1019
|
+
// Host and Path can be confusing as these aren't actually used in the cache, but are used for logging - chadkluck 2023-08-05
|
|
1020
|
+
if ( "host" in cacheProfile ) { this.#hostId = cacheProfile.host; } // we'll accept host for backwards compatibility - chadkluck 2023-08-05
|
|
1021
|
+
if ( "hostId" in cacheProfile ) { this.#hostId = cacheProfile.hostId; } // changed from host to hostId chadkluck 2023-08-05
|
|
1022
|
+
if ( "path" in cacheProfile ) { this.#pathId = cacheProfile.path; } // we'll accept path for backwards compatibility - chadkluck 2023-08-05
|
|
1023
|
+
if ( "pathId" in cacheProfile ) { this.#pathId = cacheProfile.pathId; } // changed from path to pathId chadkluck 2023-08-05
|
|
1024
|
+
|
|
1025
|
+
// Documentation uses a better term of Override rather than ignore - chadkluck 2023-08-05
|
|
1026
|
+
if ( "ignoreOriginHeaderExpires" in cacheProfile ) { this.#overrideOriginHeaderExpiration = Cache.bool(cacheProfile.ignoreOriginHeaderExpires); } // we'll accept this for backwards compatibility - chadkluck 2023-08-05
|
|
1027
|
+
if ( "ignoreOriginHeaderExpiration" in cacheProfile ) { this.#overrideOriginHeaderExpiration = Cache.bool(cacheProfile.ignoreOriginHeaderExpiration); } // we'll accept this for backwards compatibility - chadkluck 2023-08-05
|
|
1028
|
+
if ( "overrideOriginHeaderExpiration" in cacheProfile ) { this.#overrideOriginHeaderExpiration = Cache.bool(cacheProfile.overrideOriginHeaderExpiration); }
|
|
1029
|
+
|
|
1030
|
+
// We are using expiration rather than expires - chadkluck 2023-08-05
|
|
1031
|
+
if ( "defaultExpiresExtensionOnErrorInSeconds" in cacheProfile ) { this.#defaultExpirationExtensionOnErrorInSeconds = parseInt(cacheProfile.defaultExpiresExtensionOnErrorInSeconds, 10); }
|
|
1032
|
+
if ( "defaultExpirationExtensionOnErrorInSeconds" in cacheProfile ) { this.#defaultExpirationExtensionOnErrorInSeconds = parseInt(cacheProfile.defaultExpirationExtensionOnErrorInSeconds, 10); }
|
|
1033
|
+
|
|
1034
|
+
// set cacheProfile using the accepted property names
|
|
1035
|
+
if ( "headersToRetain" in cacheProfile ) { this.#headersToRetain = this.#parseHeadersToRetain(cacheProfile.headersToRetain); }
|
|
1036
|
+
if ( "encrypt" in cacheProfile ) { this.#encrypt = Cache.bool(cacheProfile.encrypt); }
|
|
1037
|
+
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// now set cache info
|
|
1041
|
+
this.#idHash = Cache.generateIdHash(connection);
|
|
1042
|
+
this.#syncedNowTimestampInSeconds = CacheData.convertTimestampFromMilliToSeconds(Date.now());
|
|
1043
|
+
this.#syncedLaterTimestampInSeconds = this.#syncedNowTimestampInSeconds + this.#defaultExpirationInSeconds; // now + default cache time
|
|
1044
|
+
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Initialize all data common to all Cache objects.
|
|
1049
|
+
* Needs to be used at the application boot,
|
|
1050
|
+
* NOT per request or after new Cache().
|
|
1051
|
+
* Environment variables can be used to set the S3 bucket, DynamoDb location, etc.
|
|
1052
|
+
* Use Cache.info() to check init values.
|
|
1053
|
+
*
|
|
1054
|
+
* Sample param object:
|
|
1055
|
+
* @example
|
|
1056
|
+
* cache.Cache.init({
|
|
1057
|
+
* dynamoDbTable: process.env.CacheData_DynamoDbTable,
|
|
1058
|
+
* s3Bucket: process.env.CacheData_S3Bucket,
|
|
1059
|
+
* secureDataAlgorithm: process.env.CacheData_CryptSecureDataAlgorithm,
|
|
1060
|
+
* secureDataKey: Buffer.from(params.app.crypt_secureDataKey, cache.Cache.CRYPT_ENCODING),
|
|
1061
|
+
* idHashAlgorithm: process.env.CacheData_CryptIdHashAlgorithm,
|
|
1062
|
+
* DynamoDbMaxCacheSize_kb: parseInt(process.env.CacheData_DynamoDb_maxCacheSize_kb, 10),
|
|
1063
|
+
* purgeExpiredCacheEntriesAfterXHours: parseInt(process.env.CacheData_PurgeExpiredCacheEntriesAfterXHours, 10),
|
|
1064
|
+
* timeZoneForInterval: process.env.CacheData_TimeZoneForInterval // if caching on interval, we need a timezone to account for calculating hours, days, and weeks. List: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
|
1065
|
+
* });
|
|
1066
|
+
*
|
|
1067
|
+
* @param {Object} parameters
|
|
1068
|
+
* @param {string} parameters.dynamoDbTable
|
|
1069
|
+
* @param {string} parameters.s3Bucket
|
|
1070
|
+
* @param {string} parameters.secureDataAlgorithm
|
|
1071
|
+
* @param {string} parameters.secureDataKey
|
|
1072
|
+
* @param {number} parameters.DynamoDbMaxCacheSize_kb
|
|
1073
|
+
* @param {number} parameters.purgeExpiredCacheEntriesAfterXHours
|
|
1074
|
+
* @param {string} parameters.timeZoneForInterval
|
|
1075
|
+
*/
|
|
1076
|
+
static init(parameters) {
|
|
1077
|
+
if ( "idHashAlgorithm" in parameters ) { this.#idHashAlgorithm = parameters.idHashAlgorithm; } else { tools.DebugAndLog.error("parameters.idHashAlgorithm not set in Cache.init()")};
|
|
1078
|
+
if ("useToolsHash" in parameters ) { this.#useToolsHash = Boolean(parameters.useToolsHash); }
|
|
1079
|
+
|
|
1080
|
+
CacheData.init(parameters);
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Returns all the common information such as hash algorithm, s3 bucket,
|
|
1085
|
+
* dynamo db location, etc.
|
|
1086
|
+
* @returns {{
|
|
1087
|
+
* idHashAlgorithm: string,
|
|
1088
|
+
* dynamoDbTable: string,
|
|
1089
|
+
* s3Bucket: string,
|
|
1090
|
+
* secureDataAlgorithm: string,
|
|
1091
|
+
* secureDataKey: string,
|
|
1092
|
+
* DynamoDbMaxCacheSize_kb: number,
|
|
1093
|
+
* purgeExpiredCacheEntriesAfterXHours: number,
|
|
1094
|
+
* timeZoneForInterval: string,
|
|
1095
|
+
* offsetInMinutes: number
|
|
1096
|
+
* }}
|
|
1097
|
+
*/
|
|
1098
|
+
static info() {
|
|
1099
|
+
return Object.assign({ idHashAlgorithm: this.#idHashAlgorithm }, CacheData.info()); // merge into 1 object and return
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
*
|
|
1104
|
+
* @returns {object} Test data of nextIntervalInSeconds method
|
|
1105
|
+
*/
|
|
1106
|
+
static testInterval () {
|
|
1107
|
+
let ts = CacheData.convertTimestampFromMilliToSeconds(Date.now());
|
|
1108
|
+
return {
|
|
1109
|
+
"info": Cache.info(),
|
|
1110
|
+
"tests": {
|
|
1111
|
+
"start": (new Date( CacheData.convertTimestampFromSecondsToMilli(ts))),
|
|
1112
|
+
"sec_15": (new Date(Cache.nextIntervalInSeconds(15, ts)*1000)),
|
|
1113
|
+
"sec_30": (new Date(Cache.nextIntervalInSeconds(30, ts)*1000)),
|
|
1114
|
+
"sec_60": (new Date(Cache.nextIntervalInSeconds(60, ts)*1000)),
|
|
1115
|
+
"min_05": (new Date(Cache.nextIntervalInSeconds(5*60, ts)*1000)),
|
|
1116
|
+
"min_10": (new Date(Cache.nextIntervalInSeconds(10*60, ts)*1000)),
|
|
1117
|
+
"min_15": (new Date(Cache.nextIntervalInSeconds(15*60, ts)*1000)),
|
|
1118
|
+
"min_30": (new Date(Cache.nextIntervalInSeconds(30*60, ts)*1000)),
|
|
1119
|
+
"min_60": (new Date(Cache.nextIntervalInSeconds(60*60, ts)*1000)),
|
|
1120
|
+
"hrs_02": (new Date(Cache.nextIntervalInSeconds(2*60*60, ts)*1000)),
|
|
1121
|
+
"hrs_03": (new Date(Cache.nextIntervalInSeconds(3*60*60, ts)*1000)),
|
|
1122
|
+
"hrs_04": (new Date(Cache.nextIntervalInSeconds(4*60*60, ts)*1000)),
|
|
1123
|
+
"hrs_05": (new Date(Cache.nextIntervalInSeconds(5*60*60, ts)*1000)),
|
|
1124
|
+
"hrs_06": (new Date(Cache.nextIntervalInSeconds(6*60*60, ts)*1000)),
|
|
1125
|
+
"hrs_08": (new Date(Cache.nextIntervalInSeconds(8*60*60, ts)*1000)),
|
|
1126
|
+
"hrs_12": (new Date(Cache.nextIntervalInSeconds(12*60*60, ts)*1000)),
|
|
1127
|
+
"hrs_24": (new Date(Cache.nextIntervalInSeconds(24*60*60, ts)*1000)),
|
|
1128
|
+
"hrs_48": (new Date(Cache.nextIntervalInSeconds(48*60*60, ts)*1000)),
|
|
1129
|
+
"hrs_72": (new Date(Cache.nextIntervalInSeconds(72*60*60, ts)*1000)),
|
|
1130
|
+
"hrs_96": (new Date(Cache.nextIntervalInSeconds(96*60*60, ts)*1000)),
|
|
1131
|
+
"hrs_120": (new Date(Cache.nextIntervalInSeconds(120*60*60, ts)*1000))
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* When testing a variable as a boolean, 0, false, and null are false,
|
|
1138
|
+
* but the string "false" is true. Since we can be dealing with JSON data,
|
|
1139
|
+
* query parameters, and strings coded as "false" we want to include the
|
|
1140
|
+
* string "false" as false.
|
|
1141
|
+
* This function only adds "false" to the list of values already considered
|
|
1142
|
+
* false by JavaScript
|
|
1143
|
+
* @param {*} value A value you want to turn to boolean
|
|
1144
|
+
* @returns {boolean} Does the value equal false according to JavaScript evaluation rules?
|
|
1145
|
+
*/
|
|
1146
|
+
static bool (value) {
|
|
1147
|
+
|
|
1148
|
+
if ( typeof value === 'string') { value = value.toLowerCase(); }
|
|
1149
|
+
|
|
1150
|
+
// Boolean("false") is true so we need to code for it. As long as it is not "false", trust Boolean()
|
|
1151
|
+
return (( value !== "false") ? Boolean(value) : false );
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Generate an etag based on an id and content body
|
|
1156
|
+
* @param {string} idHash Hashed content identifier. For web pages this a hash of host, path, query, etc.
|
|
1157
|
+
* @param {string} content Content. usually body and static headers
|
|
1158
|
+
* @returns {string} An etag specific to that content and id
|
|
1159
|
+
*/
|
|
1160
|
+
static generateEtag(idHash, content) {
|
|
1161
|
+
return CacheData.generateEtag(idHash, content);
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* To make submitted key comparison easier against a standard set of keys,
|
|
1166
|
+
* lowercase keys in the object.
|
|
1167
|
+
* @param {object} objectWithKeys Object we want to lowercase keys on
|
|
1168
|
+
* @returns {object} Object with lowercase keys
|
|
1169
|
+
*/
|
|
1170
|
+
static lowerCaseKeys(objectWithKeys) {
|
|
1171
|
+
return CacheData.lowerCaseKeys(objectWithKeys);
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Generate an internet formatted date such as those used in headers.
|
|
1176
|
+
*
|
|
1177
|
+
* Example: "Wed, 28 Jul 2021 12:24:11 GMT"
|
|
1178
|
+
*
|
|
1179
|
+
* @param {number} timestamp Unix timestamp in seconds or milliseconds.
|
|
1180
|
+
* @param {boolean} inMilliseconds Set to true if timestamp is in milliseconds. Default is false.
|
|
1181
|
+
* @returns {string} Formatted date/time such as Wed, 28 Jul 2021 12:24:11 GMT
|
|
1182
|
+
*/
|
|
1183
|
+
static generateInternetFormattedDate(timestamp, inMilliseconds = false) {
|
|
1184
|
+
return CacheData.generateInternetFormattedDate(timestamp, inMilliseconds);
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Uses object-hash to create a hash of an object passed to it
|
|
1189
|
+
* Object-hash respects object structures and arrays and performs a sort to
|
|
1190
|
+
* normalize objects so that objects with the same key/value structure are
|
|
1191
|
+
* identified as such. For example:
|
|
1192
|
+
* {host: "example.com", path: "/api"} === {path: "/api", host: "example.com"}
|
|
1193
|
+
*
|
|
1194
|
+
* You can also pass in a string such as "MYID-03-88493" if your id is not
|
|
1195
|
+
* query based.
|
|
1196
|
+
*
|
|
1197
|
+
* Note: Arrays are sorted alphabetically. So [1,2,3] will be same as
|
|
1198
|
+
* [3,1,2] and ["A","B","C"] will be same as ["B","C","A"] so if the order
|
|
1199
|
+
* of the array matters it is recommended to perform a .join prior. This is
|
|
1200
|
+
* so that:
|
|
1201
|
+
* {query: {types: "db,contact,guides"} } === {query: {types: "contact,guides,db"} }
|
|
1202
|
+
* example.com/?types=db,contact,guides === example.com/?types=contact,guides,db
|
|
1203
|
+
*
|
|
1204
|
+
* You can pass in an object containing request header and query param
|
|
1205
|
+
* represented as objects.
|
|
1206
|
+
*
|
|
1207
|
+
* As an example, CacheableDataAccess() combines 3 of the parameter objects
|
|
1208
|
+
* passed to it.
|
|
1209
|
+
* query, connection, and cachePolicy are pased as an object by
|
|
1210
|
+
* CacheableDataAccess() for Cache() to create a hashed id:
|
|
1211
|
+
* { query: query, connection: connection, cachePolicy: cachePolicy };
|
|
1212
|
+
*
|
|
1213
|
+
* Passing an array of objects such as:
|
|
1214
|
+
* [ query, connection, cachePolicy ]
|
|
1215
|
+
* also works. object-hash is REALLY cool and magical
|
|
1216
|
+
*
|
|
1217
|
+
* Uses object-hash: https://www.npmjs.com/package/object-hash
|
|
1218
|
+
* Git: https://github.com/puleos/object-hash
|
|
1219
|
+
*
|
|
1220
|
+
* Make sure it is installed in app/node_modules with an entry in app/package.json
|
|
1221
|
+
* "dependencies": {
|
|
1222
|
+
* "object-hash": "^2.2.0"
|
|
1223
|
+
* }
|
|
1224
|
+
*
|
|
1225
|
+
* @param {Object|Array|string} idObject Object, Array, or string to hash. Object may contain a single value with a text string, or complex http request broken down into parts
|
|
1226
|
+
* @returns {string} A hash representing the object (Algorithm used is set in Cache object constructor)
|
|
1227
|
+
*/
|
|
1228
|
+
static generateIdHash(idObject) {
|
|
1229
|
+
|
|
1230
|
+
// Helper function to remove functions from an object
|
|
1231
|
+
const removeFunctions = (obj) => {
|
|
1232
|
+
const newObj = {};
|
|
1233
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1234
|
+
if (typeof value === 'function') {
|
|
1235
|
+
continue; // skip functions
|
|
1236
|
+
}
|
|
1237
|
+
if (typeof value === 'object' && value !== null) {
|
|
1238
|
+
newObj[key] = removeFunctions(value); // recursively handle nested objects
|
|
1239
|
+
} else {
|
|
1240
|
+
newObj[key] = value;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return newObj;
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
// Create clean object without functions first
|
|
1247
|
+
const cleanObject = removeFunctions(idObject);
|
|
1248
|
+
|
|
1249
|
+
// Now safe to use structuredClone - deep clone idObject so we don't change the original
|
|
1250
|
+
const clonedIdObject = structuredClone(cleanObject);
|
|
1251
|
+
|
|
1252
|
+
// set salt to process.env.AWS_LAMBDA_FUNCTION_NAME if it exists, otherwise use ""
|
|
1253
|
+
const salt = process.env?.AWS_LAMBDA_FUNCTION_NAME || "";
|
|
1254
|
+
|
|
1255
|
+
// remove connection.options from clonedIdObject
|
|
1256
|
+
if ( clonedIdObject.connection?.options ) { delete clonedIdObject.connection.options; }
|
|
1257
|
+
|
|
1258
|
+
// use the built-in hashing from CacheData tools
|
|
1259
|
+
if ( this.#useToolsHash ) { return tools.hashThisData(this.#idHashAlgorithm, clonedIdObject, {salt}); }
|
|
1260
|
+
|
|
1261
|
+
// use the external package object-hash settings
|
|
1262
|
+
const objHashSettings = {
|
|
1263
|
+
algorithm: this.#idHashAlgorithm,
|
|
1264
|
+
encoding: "hex", // default, but we'll list it here anyway as it is important for this use case
|
|
1265
|
+
respectType: true, // default, but we'll list it here anyway as it is important for this use case
|
|
1266
|
+
unorderedSets: true, // default, but we'll list it here anyway as it is important for this use case
|
|
1267
|
+
unorderedObjects: true, // default, but we'll list it here anyway as it is important for this use case
|
|
1268
|
+
unorderedArrays: true // default is false but we want true - would be a problem if array sequence mattered, but not in this use case
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
// there is no salt in object-hash, so we add it to a property that would be least likely to conflict
|
|
1272
|
+
clonedIdObject.THIS_IS_SALT_FOR_CK_CACHE_DATA_ID_HASH = salt;
|
|
1273
|
+
|
|
1274
|
+
return objHash(clonedIdObject, objHashSettings);
|
|
1275
|
+
|
|
1276
|
+
};
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Converts an array to a string using a join. However, it is fluid in case
|
|
1280
|
+
* we might also be passed an id that is already a string.
|
|
1281
|
+
* @param {Array|string} identifierArrayOrString An array we wish to join together as an id. (also could be a string which we won't touch)
|
|
1282
|
+
* @param {string} glue The glue or delimiter to place between the array elements once it is in string form
|
|
1283
|
+
* @returns {string} The array in string form delimited by the glue.
|
|
1284
|
+
*/
|
|
1285
|
+
static multipartId (identifierArrayOrString, glue = "-") {
|
|
1286
|
+
let id = null;
|
|
1287
|
+
if ( Array.isArray(identifierArrayOrString) || typeof identifierArrayOrString === 'string') {
|
|
1288
|
+
id = ( Array.isArray(identifierArrayOrString) ) ? identifierArrayOrString.join(glue) : identifierArrayOrString;
|
|
1289
|
+
}
|
|
1290
|
+
return id;
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Uses Date.parse() but returns seconds instead of milliseconds.
|
|
1295
|
+
* Takes a date string (such as "2011-10-10T14:48:00") and returns the number of seconds since January 1, 1970, 00:00:00 UTC
|
|
1296
|
+
* @param {string} date
|
|
1297
|
+
* @returns {number} The date in seconds since January 1, 1970, 00:00:00 UTC
|
|
1298
|
+
*/
|
|
1299
|
+
static parseToSeconds(date) {
|
|
1300
|
+
let timestampInSeconds = 0;
|
|
1301
|
+
try {
|
|
1302
|
+
timestampInSeconds = CacheData.convertTimestampFromMilliToSeconds( Date.parse(date) );
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
tools.DebugAndLog.error(`Cannot parse date/time: ${date} ${error.message}`, error.stack);
|
|
1305
|
+
}
|
|
1306
|
+
return timestampInSeconds;
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* We can set times and expirations on intervals, such as every
|
|
1311
|
+
* 15 seconds (mm:00, mm:15, mm:30, mm:45), every half hour
|
|
1312
|
+
* (hh:00:00, hh:30:00), every hour (T00:00:00, T01:00:00), etc.
|
|
1313
|
+
* In some cases such as every 2 hours, the interval is calculated
|
|
1314
|
+
* from midnight in the timezone specified in timeZoneForInterval
|
|
1315
|
+
* Spans of days (such as every two days (48 hours) or every three
|
|
1316
|
+
* days (72 hours) are calculated from midnight of the UNIX epoch
|
|
1317
|
+
* (January 1, 1970).
|
|
1318
|
+
*
|
|
1319
|
+
* When a timezone is set in timeZoneForInterval, then there is
|
|
1320
|
+
* a slight adjustment made so that the interval lines up with
|
|
1321
|
+
* midnight of the "local" time. For example, if an organization
|
|
1322
|
+
* is primarily located in the Central Time Zone (or their
|
|
1323
|
+
* nightly batch jobs occur at GMT-05:00) then timeZoneForInterval
|
|
1324
|
+
* may be set to "America/Chicago" so that midnight in
|
|
1325
|
+
* "America/Chicago" may be used for calculations. That keeps
|
|
1326
|
+
* every 4 hours on hours 00, 04, 08, 12, 16, etc.
|
|
1327
|
+
* @param {number} intervalInSeconds
|
|
1328
|
+
* @param {number} timestampInSeconds
|
|
1329
|
+
* @returns {number} Next interval in seconds
|
|
1330
|
+
*/
|
|
1331
|
+
static nextIntervalInSeconds(intervalInSeconds, timestampInSeconds = 0) {
|
|
1332
|
+
return CacheData.nextIntervalInSeconds(intervalInSeconds, timestampInSeconds);
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Calculate the number of Kilobytes in memory a String takes up.
|
|
1338
|
+
* This function first calculates the number of bytes in the String using
|
|
1339
|
+
* Buffer.byteLength() and then converts it to KB = (bytes / 1024)
|
|
1340
|
+
* @param {string} aString A string to calculate on
|
|
1341
|
+
* @param {string} encode What character encoding should be used? Default is "utf8"
|
|
1342
|
+
* @returns String size in estimated KB
|
|
1343
|
+
*/
|
|
1344
|
+
static calculateKBytes ( aString, encode = CacheData.PLAIN_ENCODING ) {
|
|
1345
|
+
return CacheData.calculateKBytes( aString, encode);
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Converts a comma delimited string or an array to an array with all
|
|
1350
|
+
* lowercase values. Can be used to pass a comma delimited string
|
|
1351
|
+
* for conversion to an array that will then be used as (lowercase) keys.
|
|
1352
|
+
* @param {string|Array} list
|
|
1353
|
+
* @returns Array with lowercase values
|
|
1354
|
+
*/
|
|
1355
|
+
static convertToLowerCaseArray(list) {
|
|
1356
|
+
|
|
1357
|
+
// if it is an array, we'll convert to csv string
|
|
1358
|
+
if (Array.isArray(list)) {
|
|
1359
|
+
list = list.join(',');
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// lowercase the string and then convert to an array
|
|
1363
|
+
return list.toLowerCase().split(',');
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* Takes either a csv string or an Array. It will return an array
|
|
1368
|
+
* with lowercase values to be used as header keys
|
|
1369
|
+
* @param {string|Array} list
|
|
1370
|
+
* @returns Array with lowercase values
|
|
1371
|
+
*/
|
|
1372
|
+
#parseHeadersToRetain (list) {
|
|
1373
|
+
return Cache.convertToLowerCaseArray(list);
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
profile () {
|
|
1377
|
+
return {
|
|
1378
|
+
overrideOriginHeaderExpiration: this.#overrideOriginHeaderExpiration,
|
|
1379
|
+
defaultExpirationInSeconds: this.#defaultExpirationInSeconds,
|
|
1380
|
+
defaultExpirationExtensionOnErrorInSeconds: this.#defaultExpirationExtensionOnErrorInSeconds,
|
|
1381
|
+
expirationIsOnInterval: this.#expirationIsOnInterval,
|
|
1382
|
+
headersToRetain: this.#headersToRetain,
|
|
1383
|
+
hostId: this.#hostId,
|
|
1384
|
+
pathId: this.#pathId,
|
|
1385
|
+
encrypt: this.#encrypt
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
*
|
|
1391
|
+
* @returns {Promise<CacheDataFormat>}
|
|
1392
|
+
*/
|
|
1393
|
+
async read () {
|
|
1394
|
+
|
|
1395
|
+
return new Promise(async (resolve, reject) => {
|
|
1396
|
+
|
|
1397
|
+
if ( this.#store !== null ) {
|
|
1398
|
+
resolve(this.#store);
|
|
1399
|
+
} else {
|
|
1400
|
+
try {
|
|
1401
|
+
this.#store = await CacheData.read(this.#idHash, this.#syncedLaterTimestampInSeconds);
|
|
1402
|
+
this.#status = ( this.#store.cache.statusCode === null ) ? Cache.STATUS_NO_CACHE : Cache.STATUS_CACHE;
|
|
1403
|
+
|
|
1404
|
+
tools.DebugAndLog.debug(`Cache Read status: ${this.#status}`);
|
|
1405
|
+
|
|
1406
|
+
resolve(this.#store);
|
|
1407
|
+
} catch (error) {
|
|
1408
|
+
this.#store = CacheData.format(this.#syncedLaterTimestampInSeconds);
|
|
1409
|
+
this.#status = Cache.STATUS_CACHE_ERROR;
|
|
1410
|
+
|
|
1411
|
+
tools.DebugAndLog.error(`Cache Read: Cannot read cached data for ${this.#idHash}: ${error.message}`, error.stack);
|
|
1412
|
+
|
|
1413
|
+
reject(this.#store);
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
test() {
|
|
1422
|
+
return {
|
|
1423
|
+
get: this.get(),
|
|
1424
|
+
getStatus: this.getStatus(),
|
|
1425
|
+
getETag: this.getETag(),
|
|
1426
|
+
getLastModified: this.getLastModified(),
|
|
1427
|
+
getExpires: this.getExpires(),
|
|
1428
|
+
getExpiresGMT: this.getExpiresGMT(),
|
|
1429
|
+
getHeaders: this.getHeaders(),
|
|
1430
|
+
getSyncedNowTimestampInSeconds: this.getSyncedNowTimestampInSeconds(),
|
|
1431
|
+
getBody: this.getBody(),
|
|
1432
|
+
getIdHash: this.getIdHash(),
|
|
1433
|
+
getClassification: this.getClassification(),
|
|
1434
|
+
needsRefresh: this.needsRefresh(),
|
|
1435
|
+
isExpired: this.isExpired(),
|
|
1436
|
+
isEmpty: this.isEmpty(),
|
|
1437
|
+
isPrivate: this.isPrivate(),
|
|
1438
|
+
isPublic: this.isPublic(),
|
|
1439
|
+
currentStatus: this.getStatus(),
|
|
1440
|
+
calculateDefaultExpires: this.calculateDefaultExpires()
|
|
1441
|
+
};
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
*
|
|
1446
|
+
* @returns {CacheDataFormat}
|
|
1447
|
+
*/
|
|
1448
|
+
get() {
|
|
1449
|
+
return this.#store;
|
|
1450
|
+
};
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
*
|
|
1454
|
+
* @returns {string}
|
|
1455
|
+
*/
|
|
1456
|
+
getSourceStatus() {
|
|
1457
|
+
return this.#status;
|
|
1458
|
+
};
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
*
|
|
1462
|
+
* @returns {string}
|
|
1463
|
+
*/
|
|
1464
|
+
getETag() {
|
|
1465
|
+
return this.getHeader("etag");
|
|
1466
|
+
};
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
*
|
|
1470
|
+
* @returns {string} The falue of the cached header field last-modified
|
|
1471
|
+
*/
|
|
1472
|
+
getLastModified() {
|
|
1473
|
+
return this.getHeader("last-modified");
|
|
1474
|
+
};
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
*
|
|
1478
|
+
* @returns {number} Expiration timestamp in seconds
|
|
1479
|
+
*/
|
|
1480
|
+
getExpires() {
|
|
1481
|
+
let exp = (this.#store !== null) ? this.#store.cache.expires : 0;
|
|
1482
|
+
return exp;
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Get the expiration as an internet formatted date used in headers.
|
|
1487
|
+
*
|
|
1488
|
+
* Example: "Wed, 28 Jul 2021 12:24:11 GMT"
|
|
1489
|
+
*
|
|
1490
|
+
* @returns {string} The expiration formated for use in headers. Same as expires header.
|
|
1491
|
+
*/
|
|
1492
|
+
getExpiresGMT() {
|
|
1493
|
+
return this.getHeader("expires");
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
*
|
|
1498
|
+
* @returns {number} The calculated number of seconds from now until expires
|
|
1499
|
+
*/
|
|
1500
|
+
calculateSecondsLeftUntilExpires() {
|
|
1501
|
+
let secondsLeftUntilExpires = this.getExpires() - CacheData.convertTimestampFromMilliToSeconds( Date.now() );
|
|
1502
|
+
if (secondsLeftUntilExpires < 0) { secondsLeftUntilExpires = 0; }
|
|
1503
|
+
|
|
1504
|
+
return secondsLeftUntilExpires;
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* Example: public, max-age=123456
|
|
1509
|
+
* @returns {string} The value for cache-control header
|
|
1510
|
+
*/
|
|
1511
|
+
getCacheControlHeaderValue() {
|
|
1512
|
+
return this.getClassification() +", max-age="+this.calculateSecondsLeftUntilExpires();
|
|
1513
|
+
};
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
*
|
|
1517
|
+
* @returns {object|null} All the header key/value pairs for the cached object
|
|
1518
|
+
*/
|
|
1519
|
+
getHeaders() {
|
|
1520
|
+
return (this.#store !== null && "headers" in this.#store.cache) ? this.#store.cache.headers : null;
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
*
|
|
1525
|
+
* @returns {string|null} The status code of the cache object
|
|
1526
|
+
*/
|
|
1527
|
+
getStatusCode() {
|
|
1528
|
+
return (this.#store !== null && "statusCode" in this.#store.cache) ? this.#store.cache.statusCode : null;
|
|
1529
|
+
};
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
*
|
|
1533
|
+
* @returns {number} Current error code for this cache
|
|
1534
|
+
*/
|
|
1535
|
+
getErrorCode() {
|
|
1536
|
+
return this.#errorCode;
|
|
1537
|
+
};
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Classification is used in the cache header to note how the data returned
|
|
1541
|
+
* should be treated in the cache. If it is private then it should be
|
|
1542
|
+
* protected.
|
|
1543
|
+
* @returns {string} Based on whether the cache is stored as encrypted, returns "private" (encrypted) or "public" (not encrypted)
|
|
1544
|
+
*/
|
|
1545
|
+
getClassification() {
|
|
1546
|
+
return (this.#encrypt ? Cache.PRIVATE : Cache.PUBLIC );
|
|
1547
|
+
};
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
*
|
|
1551
|
+
* @returns {number} The timestamp in seconds of when the object was created and used for currency logic of the cache. (used as comparasion against expiration and for creating new expirations)
|
|
1552
|
+
*/
|
|
1553
|
+
getSyncedNowTimestampInSeconds() {
|
|
1554
|
+
return this.#syncedNowTimestampInSeconds;
|
|
1555
|
+
};
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
*
|
|
1559
|
+
* @param {string} key The header key to access
|
|
1560
|
+
* @returns {string|number|null} The value assigned to the provided header key. null if it doesn't exist
|
|
1561
|
+
*/
|
|
1562
|
+
getHeader(key) {
|
|
1563
|
+
let headers = this.getHeaders();
|
|
1564
|
+
return ( headers !== null && key in headers) ? headers[key] : null
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
*
|
|
1569
|
+
* @param {boolean} parseBody If set to true then JSON decode will be used on the body before returning.
|
|
1570
|
+
* @returns {string|object|null} A string (as is) which could be encoded JSON but we want to leave it that way, an object if parseBody is set to true and it is parsable by JSON, or null if body is null
|
|
1571
|
+
*/
|
|
1572
|
+
getBody(parseBody = false) {
|
|
1573
|
+
let body = (this.#store !== null) ? this.#store.cache.body : null;
|
|
1574
|
+
let bodyToReturn = null;
|
|
1575
|
+
|
|
1576
|
+
try {
|
|
1577
|
+
bodyToReturn = (body !== null && parseBody) ? JSON.parse(body) : body;
|
|
1578
|
+
} catch (error) {
|
|
1579
|
+
tools.DebugAndLog.error(`Cache.getBody() parse error: ${error.message}`, error.stack);
|
|
1580
|
+
tools.DebugAndLog.debug("Error parsing body", body);
|
|
1581
|
+
};
|
|
1582
|
+
|
|
1583
|
+
return (( bodyToReturn !== null) ? bodyToReturn : body );
|
|
1584
|
+
};
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Returns a plain data response in the form of an object. If a full HTTP
|
|
1588
|
+
* response is needed use generateResponseForAPIGateway()
|
|
1589
|
+
* @param {boolean} parseBody If true we'll return body as object
|
|
1590
|
+
* @returns {{statusCode: string, headers: object, body: string|object}} a plain data response in the form of an object
|
|
1591
|
+
*/
|
|
1592
|
+
getResponse(parseBody = false) {
|
|
1593
|
+
let response = null;
|
|
1594
|
+
|
|
1595
|
+
if (this.#store !== null) {
|
|
1596
|
+
response = {
|
|
1597
|
+
statusCode: this.getStatusCode(),
|
|
1598
|
+
headers: this.getHeaders(),
|
|
1599
|
+
body: ( this.getBody(parseBody))
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
return response;
|
|
1604
|
+
};
|
|
1605
|
+
|
|
1606
|
+
/**
|
|
1607
|
+
*
|
|
1608
|
+
* @param {object} parameters
|
|
1609
|
+
* @returns {{statusCode: string, headers: object, body: string}}
|
|
1610
|
+
*/
|
|
1611
|
+
generateResponseForAPIGateway( parameters ) {
|
|
1612
|
+
|
|
1613
|
+
const ifNoneMatch = ( ("ifNoneMatch" in parameters) ? parameters.ifNoneMatch : null);
|
|
1614
|
+
const ifModifiedSince = ( ("ifModifiedSince" in parameters) ? parameters.ifModifiedSince : null);
|
|
1615
|
+
|
|
1616
|
+
const response = this.getResponse(false);
|
|
1617
|
+
|
|
1618
|
+
const additionalHeaders = {
|
|
1619
|
+
"access-control-allow-origin": "*", // we've already checked referer access, and since this can only list one host it presents issues if it can be used across a set of hosts
|
|
1620
|
+
"cache-control": this.getCacheControlHeaderValue(),
|
|
1621
|
+
"x-cprxy-data-source": this.getSourceStatus()
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
// see if the client sent conditionals to elicit a 304 not modified and respond accordingly
|
|
1625
|
+
if (
|
|
1626
|
+
(ifNoneMatch !== null && "etag" in response.headers && ifNoneMatch === response.headers.etag)
|
|
1627
|
+
|| (ifModifiedSince !== null && "last-modified" in response.headers && Date.parse(ifModifiedSince) >= Date.parse(response.headers['last-modified']) )
|
|
1628
|
+
) {
|
|
1629
|
+
// etag and last-modified match, so the client has the most recent copy in it's cache
|
|
1630
|
+
response.statusCode = "304"; // return a Not Modified
|
|
1631
|
+
response.body = null;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/*
|
|
1635
|
+
Note: The response for an OK (200) status can be empty ("")
|
|
1636
|
+
However, if a response code is not allowed to return a body, it is set
|
|
1637
|
+
to null to signify that it should not be included in the response and
|
|
1638
|
+
filtered out at this step.
|
|
1639
|
+
*/
|
|
1640
|
+
|
|
1641
|
+
// set the statusCode if null
|
|
1642
|
+
if (response.statusCode === null) {
|
|
1643
|
+
response.statusCode = "200";
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
response.headers = Object.assign(response.headers, additionalHeaders);
|
|
1647
|
+
|
|
1648
|
+
return response;
|
|
1649
|
+
|
|
1650
|
+
};
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
*
|
|
1654
|
+
* @returns {string}
|
|
1655
|
+
*/
|
|
1656
|
+
getIdHash() {
|
|
1657
|
+
return this.#idHash;
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
*
|
|
1662
|
+
* @returns {boolean}
|
|
1663
|
+
*/
|
|
1664
|
+
needsRefresh() {
|
|
1665
|
+
return (this.isExpired() || this.isEmpty());
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
*
|
|
1670
|
+
* @returns {boolean}
|
|
1671
|
+
*/
|
|
1672
|
+
isExpired() {
|
|
1673
|
+
return ( CacheData.convertTimestampFromSecondsToMilli(this.getExpires()) <= Date.now());
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
*
|
|
1678
|
+
* @returns {boolean}
|
|
1679
|
+
*/
|
|
1680
|
+
isEmpty() {
|
|
1681
|
+
return (this.#store.cache.statusCode === null);
|
|
1682
|
+
};
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
*
|
|
1686
|
+
* @returns {boolean}
|
|
1687
|
+
*/
|
|
1688
|
+
isPrivate() {
|
|
1689
|
+
return this.#encrypt;
|
|
1690
|
+
};
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
*
|
|
1694
|
+
* @returns {boolean}
|
|
1695
|
+
*/
|
|
1696
|
+
isPublic() {
|
|
1697
|
+
return !this.#encrypt;
|
|
1698
|
+
};
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
*
|
|
1702
|
+
* @param {string} reason Reason for extending, either Cache.STATUS_ORIGINAL_ERROR or Cache.STATUS_ORIGINAL_NOT_MODIFIED
|
|
1703
|
+
* @param {number} seconds
|
|
1704
|
+
* @param {number} errorCode
|
|
1705
|
+
*/
|
|
1706
|
+
extendExpires(reason, seconds = 0, errorCode = 0) {
|
|
1707
|
+
|
|
1708
|
+
try {
|
|
1709
|
+
|
|
1710
|
+
let cache = this.#store.cache;
|
|
1711
|
+
|
|
1712
|
+
// we will extend based on error extention if in error, we'll look at passed seconds and non-error default later
|
|
1713
|
+
if (seconds === 0 && reason === Cache.STATUS_ORIGINAL_ERROR) {
|
|
1714
|
+
seconds = this.#defaultExpirationExtensionOnErrorInSeconds;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// if the cache exists, we'll extend it
|
|
1718
|
+
if ( cache !== null ) {
|
|
1719
|
+
// statusCode
|
|
1720
|
+
let statusCode = (cache.statusCode !== null) ? cache.statusCode : errorCode ;
|
|
1721
|
+
|
|
1722
|
+
// we are going to create a new expires header, so delete it if it exists so we start from now()
|
|
1723
|
+
if (cache.headers !== null && "expires" in cache.headers) { delete cache.headers.expires; }
|
|
1724
|
+
|
|
1725
|
+
// calculate the new expires based on default (seconds === 0) or now() + seconds passed to this function
|
|
1726
|
+
let expires = (seconds === 0) ? this.calculateDefaultExpires() : this.#syncedNowTimestampInSeconds + seconds;
|
|
1727
|
+
|
|
1728
|
+
// if a reason was passed, use it only if it is a valid reason for extending. Otherwise null
|
|
1729
|
+
let status = (reason === Cache.STATUS_ORIGINAL_ERROR || reason === Cache.STATUS_ORIGINAL_NOT_MODIFIED) ? reason : null;
|
|
1730
|
+
|
|
1731
|
+
// if we received an error, add it in in case we want to evaluate further
|
|
1732
|
+
if (errorCode >= 400) { this.#errorCode = errorCode; }
|
|
1733
|
+
|
|
1734
|
+
// perform the update with existing info, but new expires and status
|
|
1735
|
+
this.update( cache.body, cache.headers, statusCode, expires, status);
|
|
1736
|
+
} else {
|
|
1737
|
+
tools.DebugAndLog.debug("Cache is null. Nothing to extend.");
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
} catch (error) {
|
|
1741
|
+
tools.DebugAndLog.error(`Unable to extend cache: ${error.message}`, error.stack);
|
|
1742
|
+
};
|
|
1743
|
+
|
|
1744
|
+
};
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
*
|
|
1748
|
+
* @returns {number}
|
|
1749
|
+
*/
|
|
1750
|
+
calculateDefaultExpires() {
|
|
1751
|
+
return (this.#expirationIsOnInterval) ? Cache.nextIntervalInSeconds(this.#defaultExpirationInSeconds, this.#syncedNowTimestampInSeconds) : this.#syncedLaterTimestampInSeconds;
|
|
1752
|
+
};
|
|
1753
|
+
|
|
1754
|
+
/**
|
|
1755
|
+
*
|
|
1756
|
+
* @returns {string}
|
|
1757
|
+
*/
|
|
1758
|
+
getStatus() {
|
|
1759
|
+
return this.#status;
|
|
1760
|
+
};
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Store data in cache. Returns a representation of data stored in the cache
|
|
1764
|
+
* @param {object} body
|
|
1765
|
+
* @param {object} headers Any headers you want to pass along, including last-modified, etag, and expires. Note that if expires is included as a header here, then it will override the expires paramter passed to .update()
|
|
1766
|
+
* @param {number} statusCode Status code of original request
|
|
1767
|
+
* @param {number} expires Expiration unix timestamp in seconds
|
|
1768
|
+
* @returns {CacheDataFormat} Representation of data stored in cache
|
|
1769
|
+
*/
|
|
1770
|
+
update (body, headers, statusCode = 200, expires = 0, status = null) {
|
|
1771
|
+
|
|
1772
|
+
const prev = {
|
|
1773
|
+
eTag: this.getETag(),
|
|
1774
|
+
modified: this.getLastModified(),
|
|
1775
|
+
expired: this.isExpired(),
|
|
1776
|
+
empty: this.isEmpty()
|
|
1777
|
+
};
|
|
1778
|
+
|
|
1779
|
+
// lowercase all the header keys so we can evaluate each
|
|
1780
|
+
headers = Cache.lowerCaseKeys(headers);
|
|
1781
|
+
|
|
1782
|
+
/* Bring in headers
|
|
1783
|
+
We'll keep the etag and last-modified. Also any specified
|
|
1784
|
+
*/
|
|
1785
|
+
let defaultHeadersToRetain = [
|
|
1786
|
+
"content-type",
|
|
1787
|
+
"etag",
|
|
1788
|
+
"last-modified",
|
|
1789
|
+
"ratelimit-limit",
|
|
1790
|
+
"ratelimit-remaining",
|
|
1791
|
+
"ratelimit-reset",
|
|
1792
|
+
"x-ratelimit-limit",
|
|
1793
|
+
"x-ratelimit-remaining",
|
|
1794
|
+
"x-ratelimit-reset",
|
|
1795
|
+
"retry-after"
|
|
1796
|
+
];
|
|
1797
|
+
|
|
1798
|
+
// combine the standard headers with the headers specified for endpoint in custom/policies.json
|
|
1799
|
+
let ptHeaders = [].concat(this.#headersToRetain, defaultHeadersToRetain);
|
|
1800
|
+
|
|
1801
|
+
// lowercase the headers we are looking for
|
|
1802
|
+
let passThrough = ptHeaders.map(element => {
|
|
1803
|
+
return element.toLowerCase();
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
let headersForCache = {};
|
|
1807
|
+
|
|
1808
|
+
// retain specified headers
|
|
1809
|
+
passThrough.forEach(function( key ) {
|
|
1810
|
+
if (key in headers) { headersForCache[key] = headers[key]; }
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
// we'll set the default expires, in case the expires in header does not work out, or we don't use the header expires
|
|
1814
|
+
if ( isNaN(expires) || expires === 0) {
|
|
1815
|
+
expires = this.calculateDefaultExpires();
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// get the expires and max age (as timestamp)from headers if we don't insist on overriding
|
|
1819
|
+
// unlike etag and last-modified, we won't move them over, but let the expires param in .update() do the talking
|
|
1820
|
+
if ( !this.#overrideOriginHeaderExpiration && ("expires" in headers || ("cache-control" in headers && headers['cache-control'].includes("max-age") ))) {
|
|
1821
|
+
|
|
1822
|
+
let age = this.#syncedNowTimestampInSeconds;
|
|
1823
|
+
let exp = this.#syncedNowTimestampInSeconds;
|
|
1824
|
+
|
|
1825
|
+
if ("cache-control" in headers && headers['cache-control'].includes("max-age")) {
|
|
1826
|
+
// extract max-age
|
|
1827
|
+
let cacheControl = headers['cache-control'].split(",");
|
|
1828
|
+
for(const p of cacheControl) {
|
|
1829
|
+
if(p.trim().startsWith("max-age")) {
|
|
1830
|
+
let maxage = parseInt(p.trim().split("=")[1], 10);
|
|
1831
|
+
age = this.#syncedNowTimestampInSeconds + maxage; // convert to timestamp
|
|
1832
|
+
break; // break out of for
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
if ("expires" in headers) {
|
|
1838
|
+
exp = Cache.parseToSeconds(headers.expires);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// we will take the greater of max-age or expires, and if they are not 0 and not past, use it as expTimestamp
|
|
1842
|
+
let max = ( exp > age ) ? exp : age;
|
|
1843
|
+
if ( max !== 0 && expires > this.#syncedNowTimestampInSeconds) { expires = max; }
|
|
1844
|
+
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/* Write to Cache
|
|
1848
|
+
We are now ready to write to the cache
|
|
1849
|
+
*/
|
|
1850
|
+
try {
|
|
1851
|
+
this.#store = CacheData.write(this.#idHash, this.#syncedNowTimestampInSeconds, body, headersForCache, this.#hostId, this.#pathId, expires, statusCode, this.#encrypt);
|
|
1852
|
+
|
|
1853
|
+
if (status === null) {
|
|
1854
|
+
if (prev.empty) {
|
|
1855
|
+
status = Cache.STATUS_NO_CACHE;
|
|
1856
|
+
} else if (this.getETag() === prev.eTag || this.getLastModified() === prev.modified) {
|
|
1857
|
+
status = Cache.STATUS_CACHE_SAME;
|
|
1858
|
+
} else if (prev.expired) {
|
|
1859
|
+
status = Cache.STATUS_EXPIRED;
|
|
1860
|
+
} else {
|
|
1861
|
+
status = Cache.STATUS_FORCED;
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
this.#status = status;
|
|
1866
|
+
|
|
1867
|
+
tools.DebugAndLog.debug("Cache Updated "+this.getStatus()+": "+this.#idHash);
|
|
1868
|
+
|
|
1869
|
+
} catch (error) {
|
|
1870
|
+
tools.DebugAndLog.error(`Cannot copy cached data to local store for evaluation: ${this.#idHash} ${error.message}`, error.stack);
|
|
1871
|
+
if ( this.#store === null ) {
|
|
1872
|
+
this.#store = CacheData.format(this.#syncedLaterTimestampInSeconds);
|
|
1873
|
+
}
|
|
1874
|
+
this.#status = Cache.STATUS_CACHE_ERROR;
|
|
1875
|
+
};
|
|
1876
|
+
|
|
1877
|
+
return this.#store;
|
|
1878
|
+
|
|
1879
|
+
};
|
|
1880
|
+
|
|
1881
|
+
};
|
|
1882
|
+
|
|
1883
|
+
class CacheableDataAccess {
|
|
1884
|
+
constructor() { };
|
|
1885
|
+
|
|
1886
|
+
static #prevId = -1;
|
|
1887
|
+
|
|
1888
|
+
static #getNextId() {
|
|
1889
|
+
this.#prevId++;
|
|
1890
|
+
return ""+this.#prevId;
|
|
1891
|
+
};
|
|
1892
|
+
|
|
1893
|
+
static async prime() {
|
|
1894
|
+
return CacheData.prime();
|
|
1895
|
+
};
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* Data access object that will evaluate the cache and make a request to
|
|
1899
|
+
* an endpoint to refresh.
|
|
1900
|
+
*
|
|
1901
|
+
* @example
|
|
1902
|
+
* cachePolicy = {
|
|
1903
|
+
* overrideOriginHeaderExpiration: true,
|
|
1904
|
+
* defaultExpirationInSeconds: 60,
|
|
1905
|
+
* expirationIsOnInterval: true,
|
|
1906
|
+
* headersToRetain: [],
|
|
1907
|
+
* host: vars.policy.host,
|
|
1908
|
+
* path: vars.policy.endpoint.path,
|
|
1909
|
+
* encrypt: true
|
|
1910
|
+
* }
|
|
1911
|
+
*
|
|
1912
|
+
* connection = {
|
|
1913
|
+
* method: vars.method,
|
|
1914
|
+
* protocol: vars.protocol,
|
|
1915
|
+
* host: vars.host,
|
|
1916
|
+
* path: vars.path,
|
|
1917
|
+
* parameters: vars.parameters,
|
|
1918
|
+
* headers: vars.requestHeaders,
|
|
1919
|
+
* options: {timeout: vars.timeout}
|
|
1920
|
+
* }
|
|
1921
|
+
*
|
|
1922
|
+
* @param {object} cachePolicy A cache policy object.
|
|
1923
|
+
* @param {boolean} cachePolicy.overrideOriginHeaderExpiration
|
|
1924
|
+
* @param {number} cachePolicy.defaultExpirationInSeconds
|
|
1925
|
+
* @param {boolean} cachePolicy.expirationIsOnInterval
|
|
1926
|
+
* @param {Array|string} cachePolicy.headersToRetain
|
|
1927
|
+
* @param {string} cachePolicy.hostId
|
|
1928
|
+
* @param {string} cachePolicy.pathId
|
|
1929
|
+
* @param {boolean} cachePolicy.encrypt
|
|
1930
|
+
* @param {object} apiCallFunction The function to call in order to make the request. This function can call ANY datasource (file, http endpoint, etc) as long as it returns a DAO object
|
|
1931
|
+
* @param {object} connection A connection object that specifies an id, location, and connectin details for the apiCallFunction to access data. If you have a Connection object pass conn.toObject()
|
|
1932
|
+
* @param {string} connection.method
|
|
1933
|
+
* @param {string} connection.protocol
|
|
1934
|
+
* @param {string} connection.host
|
|
1935
|
+
* @param {string} connection.path
|
|
1936
|
+
* @param {object} connection.parameters
|
|
1937
|
+
* @param {object} connection.headers
|
|
1938
|
+
* @param {string} connection.body For POST requests a body with data may be sent.
|
|
1939
|
+
* @param {object} connection.options
|
|
1940
|
+
* @param {number} connection.options.timeout Number in ms for request to time out
|
|
1941
|
+
* @param {object} data An object passed to the apiCallFunction as a parameter. Set to null if the apiCallFunction does not require a data param
|
|
1942
|
+
* @param {object} tags For logging. Do not include sensitive information.
|
|
1943
|
+
* @returns {Promise<Cache>} A Cache object with either cached or fresh data.
|
|
1944
|
+
*/
|
|
1945
|
+
static async getData(cachePolicy, apiCallFunction, connection, data = null, tags = {} ) {
|
|
1946
|
+
|
|
1947
|
+
return new Promise(async (resolve, reject) => {
|
|
1948
|
+
|
|
1949
|
+
CacheData.prime(); // prime anything we'll need that may have changed since init, we'll await the result before read and write
|
|
1950
|
+
|
|
1951
|
+
/* tags and id have no bearing on the idHash, it is only for human readable logs */
|
|
1952
|
+
if ( !("path" in tags) ) { tags.path = [cachePolicy.hostId.replace(/^\/|\/$/g, ''), cachePolicy.pathId.replace(/^\/|\/$/g, '')]; } // we don't want extra / in the glue
|
|
1953
|
+
if ( !("id" in tags) ) { tags.id = this.#getNextId(); }
|
|
1954
|
+
|
|
1955
|
+
tags.path = Cache.multipartId(tags.path, "/");
|
|
1956
|
+
tags.id = Cache.multipartId(tags.id, "/");
|
|
1957
|
+
|
|
1958
|
+
const timer = new tools.Timer(`timerGetCacheableData_${tags.path}::${tags.id}`, true);
|
|
1959
|
+
const idToHash = { data: data, connection: connection, cachePolicy: cachePolicy };
|
|
1960
|
+
const cache = new Cache(idToHash, cachePolicy);
|
|
1961
|
+
const idHash = cache.getIdHash();
|
|
1962
|
+
|
|
1963
|
+
try {
|
|
1964
|
+
|
|
1965
|
+
await cache.read();
|
|
1966
|
+
|
|
1967
|
+
if ( cache.needsRefresh() ) {
|
|
1968
|
+
|
|
1969
|
+
tools.DebugAndLog.debug("Cache needs refresh.");
|
|
1970
|
+
|
|
1971
|
+
// add etag and last modified to connection
|
|
1972
|
+
if ( !("headers" in connection)) { connection.headers = {}; }
|
|
1973
|
+
if ( !("if-none-match" in connection.headers) && cache.getETag() !== null) {
|
|
1974
|
+
connection.headers['if-none-match'] = cache.getETag();
|
|
1975
|
+
}
|
|
1976
|
+
if (!("if-modified-since" in connection.headers) && cache.getLastModified() !== null) {
|
|
1977
|
+
connection.headers['if-modified-since'] = cache.getLastModified();
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// request data from original source
|
|
1981
|
+
let originalSource = await apiCallFunction(connection, data);
|
|
1982
|
+
|
|
1983
|
+
if ( originalSource.success ) {
|
|
1984
|
+
|
|
1985
|
+
try {
|
|
1986
|
+
// check header and status for 304 not modified
|
|
1987
|
+
if (originalSource.statusCode === 304) {
|
|
1988
|
+
tools.DebugAndLog.debug("Received 304 Not Modified. Extending cache");
|
|
1989
|
+
cache.extendExpires(Cache.STATUS_ORIGINAL_NOT_MODIFIED, 0, originalSource.statusCode);
|
|
1990
|
+
} else {
|
|
1991
|
+
let body = ( typeof originalSource.body !== "object" ) ? originalSource.body : JSON.stringify(originalSource.body);
|
|
1992
|
+
await CacheData.prime(); // can't proceed until we have the secrets
|
|
1993
|
+
cache.update(body, originalSource.headers, originalSource.statusCode);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
} catch (error) {
|
|
1997
|
+
tools.DebugAndLog.error(`Not successful in creating cache: ${idHash} (${tags.path}/${tags.id}) ${error.message}`, error.stack);
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
} else {
|
|
2001
|
+
|
|
2002
|
+
tools.DebugAndLog.error(`${originalSource.statusCode} | Not successful in getting data from original source for cache. Extending cache expires. ${idHash} (${tags.path}/${tags.id})`, originalSource);
|
|
2003
|
+
cache.extendExpires(Cache.STATUS_ORIGINAL_ERROR, 0, originalSource.statusCode);
|
|
2004
|
+
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
timer.stop();
|
|
2009
|
+
tools.DebugAndLog.log(`${idHash} | ${tags.path} | ${cache.getStatus()} | ${timer.elapsed()}`, "CACHE");
|
|
2010
|
+
resolve(cache);
|
|
2011
|
+
|
|
2012
|
+
} catch (error) {
|
|
2013
|
+
timer.stop();
|
|
2014
|
+
tools.DebugAndLog.error(`Error while getting data: (${tags.path}/${tags.id}) ${error.message}`, error.stack);
|
|
2015
|
+
reject(cache);
|
|
2016
|
+
};
|
|
2017
|
+
});
|
|
2018
|
+
};
|
|
2019
|
+
};
|
|
2020
|
+
|
|
2021
|
+
module.exports = {
|
|
2022
|
+
Cache,
|
|
2023
|
+
CacheableDataAccess
|
|
2024
|
+
};
|