From 8780bec2c2bfc32c6d6a5cc9c8b166246222e503 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Thu, 13 Jul 2023 16:08:46 +0100 Subject: [PATCH] Date field serialization (#16) * Date field serialization Fixes #14 * Add links to JSON stringify and parse * Prove that the original object is not modified --- README.md | 17 +++ src/deep-replace-dates-with-strings.spec.ts | 57 +++++++ src/deep-replace-dates-with-strings.ts | 22 +++ src/dynamodb-store.table.spec.ts | 158 ++++++++++++++++++++ src/dynamodb-store.ts | 3 +- 5 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 src/deep-replace-dates-with-strings.spec.ts create mode 100644 src/deep-replace-dates-with-strings.ts diff --git a/README.md b/README.md index 598e33c..70d20d7 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,23 @@ app.listen(Number.parseInt(PORT, 10), () => { }); ``` +## Supported Field Types + +The following field types are fully supported by the `DynamoDBStore`: + +- `string` +- `number` +- `boolean` +- `object` + +The following field types are partially supported by the `DynamoDBStore`: + +- `Date` + - Stored as a string in ISO 8601 format + - Will be returend as a string in ISO 8601 format + - Cannot be automatically converted back into a `Date` object since it is not known which fields were originally `Date` objects vs date strings + - Note: [connect-dynamodb](https://www.npmjs.com/package/connect-dynamodb) serializes `Date` objects to strings as well and also does not support automatic conversion back to `Date` objects since it serializes using [JSON.stringify()](https://github.com/ca98am79/connect-dynamodb/blob/87028bb10fa3c9d4b8adf4f6cdeea2c41c0e8f23/lib/connect-dynamodb.js#L203) and [JSON.parse()](https://github.com/ca98am79/connect-dynamodb/blob/87028bb10fa3c9d4b8adf4f6cdeea2c41c0e8f23/lib/connect-dynamodb.js#L185) + ## API Documentation After installing the package review the [API Documentation](https://pwrdrvr.github.io/dynamodb-session-store/classes/DynamoDBStore.html) for detailed on each configuration option. diff --git a/src/deep-replace-dates-with-strings.spec.ts b/src/deep-replace-dates-with-strings.spec.ts new file mode 100644 index 0000000..9130a5f --- /dev/null +++ b/src/deep-replace-dates-with-strings.spec.ts @@ -0,0 +1,57 @@ +import { deepReplaceDatesWithISOStrings } from './deep-replace-dates-with-strings'; + +describe('deepReplaceDatesWithISOStrings', () => { + it('should replace Date objects with their ISO string representation', () => { + const date1 = new Date(); + const date2 = new Date(); + const date3 = new Date(); + + const obj = { + name: 'John', + created: date1, + friends: [ + { + name: 'Jane', + created: date2, + }, + ], + latestLog: { + time: date3, + message: 'Hello, world!', + }, + }; + + const result = deepReplaceDatesWithISOStrings(obj); + + expect(result.created).toBe(date1.toISOString()); + expect(result.friends[0].created).toBe(date2.toISOString()); + expect(result.latestLog.time).toBe(date3.toISOString()); + expect(result).toEqual({ + name: 'John', + created: date1.toISOString(), + friends: [ + { + name: 'Jane', + created: date2.toISOString(), + }, + ], + latestLog: { + time: date3.toISOString(), + message: 'Hello, world!', + }, + }); + }); + + // Check that original object is unmodified + it('should not modify the original object', () => { + const date = new Date(); + const obj = { + name: 'John', + created: date, + }; + + deepReplaceDatesWithISOStrings(obj); + + expect(obj.created).toBe(date); + }); +}); diff --git a/src/deep-replace-dates-with-strings.ts b/src/deep-replace-dates-with-strings.ts new file mode 100644 index 0000000..31b068d --- /dev/null +++ b/src/deep-replace-dates-with-strings.ts @@ -0,0 +1,22 @@ +/** + * Deep clones the object and replaces all Date objects with their ISO string representation. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function deepReplaceDatesWithISOStrings(obj: any): any { + if (obj instanceof Date) { + return obj.toISOString(); + } else if (Array.isArray(obj)) { + return obj.map(deepReplaceDatesWithISOStrings); + } else if (typeof obj === 'object' && obj !== null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: any = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + result[key] = deepReplaceDatesWithISOStrings(obj[key]); + } + } + return result; + } else { + return obj; + } +} diff --git a/src/dynamodb-store.table.spec.ts b/src/dynamodb-store.table.spec.ts index dbc682a..ee2fc87 100644 --- a/src/dynamodb-store.table.spec.ts +++ b/src/dynamodb-store.table.spec.ts @@ -203,5 +203,163 @@ describe('dynamodb-store - table via jest-dynalite', () => { }, ); }); + + it('can serialize Date objects to strings', (done) => { + const store = new DynamoDBStore({ + dynamoDBClient: dynamoClient, + tableName, + }); + + const originalSessionObject = { + // Use a static date to ensure the same value is stored and retrieved + dateField: new Date('2021-07-01T01:02:03Z'), + }; + + store.set( + '129', + { + mySessionInfo: originalSessionObject, + // @ts-expect-error something + cookie: { + maxAge: 60 * 60 * 1000, // one hour in milliseconds + }, + }, + (err) => { + expect(err).toBeNull(); + + ddbDocClient + .send(new GetCommand({ TableName: tableName, Key: { id: 'session#129' } })) + .then(({ Item }) => { + expect(Item).toBeDefined(); + expect(Item!.sess).toBeDefined(); + expect(Item!.sess.mySessionInfo).toBeDefined(); + expect(Item!.sess.mySessionInfo.dateField).toBeDefined(); + + // Check that the DB has a string + expect(Item!.sess.mySessionInfo.dateField).toBe('2021-07-01T01:02:03.000Z'); + + store.get('129', (err, session) => { + expect(err).toBeNull(); + expect(session).toBeDefined(); + + // @ts-expect-error yes mySessionInfo exists + const typedSession = session as { + mySessionInfo: { + dateField: Date; + }; + }; + + expect(typedSession!.mySessionInfo).toBeDefined(); + // The date field is not going to be a date object + // since we do not have a schema to know which fields to + // convert to back into dates and which were strings + // to begin with + // expect(typedSession!.mySessionInfo.dateField).toBeInstanceOf(Date); + expect(typedSession!.mySessionInfo.dateField).toEqual('2021-07-01T01:02:03.000Z'); + + // Confirm that the original field is still a date object + expect(originalSessionObject.dateField).toBeInstanceOf(Date); + + done(); + }); + }) + .catch((err) => { + done(err); + }); + }, + ); + }); + + it('can serialize / deserialize string / object / boolean / number values', (done) => { + const store = new DynamoDBStore({ + dynamoDBClient: dynamoClient, + tableName, + }); + + store.set( + '129', + { + mySessionInfo: { + stringField: 'some string', + numberField: 123, + floatingNumberField: 123.456, + someBooleanField: true, + someOtherBooleanField: false, + someObjectField: { + nestedField: 'nested value', + }, + someUndefinedField: undefined, + someNullField: null, + }, + // @ts-expect-error something + cookie: { + maxAge: 60 * 60 * 1000, // one hour in milliseconds + }, + }, + (err) => { + expect(err).toBeNull(); + + ddbDocClient + .send(new GetCommand({ TableName: tableName, Key: { id: 'session#129' } })) + .then(({ Item }) => { + expect(Item).toBeDefined(); + expect(Item!.sess).toBeDefined(); + expect(Item!.sess.mySessionInfo).toBeDefined(); + + // Check that the DB record looks correct + expect(Item!.sess.mySessionInfo.stringField).toBe('some string'); + expect(Item!.sess.mySessionInfo.numberField).toBe(123); + expect(Item!.sess.mySessionInfo.floatingNumberField).toBe(123.456); + expect(Item!.sess.mySessionInfo.someBooleanField).toBe(true); + expect(Item!.sess.mySessionInfo.someOtherBooleanField).toBe(false); + expect(Item!.sess.mySessionInfo.someObjectField).toBeDefined(); + expect(Item!.sess.mySessionInfo.someObjectField.nestedField).toBe('nested value'); + expect(Item!.sess.mySessionInfo.someUndefinedField).toBeUndefined(); + expect(Item!.sess.mySessionInfo.someNullField).toBeNull(); + + store.get('129', (err, session) => { + expect(err).toBeNull(); + expect(session).toBeDefined(); + + // @ts-expect-error yes mySessionInfo exists + const typedSession = session as { + mySessionInfo: { + stringField: string; + numberField: number; + floatingNumberField: number; + someBooleanField: boolean; + someOtherBooleanField: boolean; + someObjectField: { + nestedField: string; + }; + someUndefinedField: undefined; + someNullField: null; + }; + }; + + expect(typedSession!.mySessionInfo).toBeDefined(); + + // Check that the values are correct + expect(typedSession!.mySessionInfo.stringField).toBe('some string'); + expect(typedSession!.mySessionInfo.numberField).toBe(123); + expect(typedSession!.mySessionInfo.floatingNumberField).toBe(123.456); + expect(typedSession!.mySessionInfo.someBooleanField).toBe(true); + expect(typedSession!.mySessionInfo.someOtherBooleanField).toBe(false); + expect(typedSession!.mySessionInfo.someObjectField).toBeDefined(); + expect(typedSession!.mySessionInfo.someObjectField.nestedField).toBe( + 'nested value', + ); + expect(typedSession!.mySessionInfo.someUndefinedField).toBeUndefined(); + expect(typedSession!.mySessionInfo.someNullField).toBeNull(); + + done(); + }); + }) + .catch((err) => { + done(err); + }); + }, + ); + }); }); }); diff --git a/src/dynamodb-store.ts b/src/dynamodb-store.ts index 55ce8d5..055b84c 100644 --- a/src/dynamodb-store.ts +++ b/src/dynamodb-store.ts @@ -9,6 +9,7 @@ import { import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'; import Debug from 'debug'; import { promisify } from 'util'; +import { deepReplaceDatesWithISOStrings } from './deep-replace-dates-with-strings'; const sleep = promisify(setTimeout); const debug = Debug('@pwrdrvr/dynamodb-session-store'); @@ -464,7 +465,7 @@ export class DynamoDBStore extends session.Store { // so we strip the fields that we don't want and make sure the `expires` field // is turned into a string sess: { - ...session, + ...deepReplaceDatesWithISOStrings(session), ...(session.cookie ? { cookie: { ...JSON.parse(JSON.stringify(session.cookie)) } } : {}),