From 2f1fad5668dce8adcd3cac6587a3d1318fcc5494 Mon Sep 17 00:00:00 2001 From: Hamza Sajid Date: Wed, 31 Jan 2024 15:20:43 +0100 Subject: [PATCH 1/2] fix(save): throw validation error if required key is missing --- src/base-model.ts | 9 ++++- src/decorators.ts | 98 +++++++++++++++++++++++++++++++++++++++++++++++ test/save.spec.ts | 38 +++++++++++++++++- 3 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 src/decorators.ts diff --git a/src/base-model.ts b/src/base-model.ts index 4c8cf26..3181322 100644 --- a/src/base-model.ts +++ b/src/base-model.ts @@ -22,6 +22,7 @@ import Scan from './scan'; import { IUpdateActions, buildUpdateActions, put } from './update-operators'; import ValidationError from './validation-error'; import { PutCommandInput } from '@aws-sdk/lib-dynamodb/dist-types/commands/PutCommand'; +import { hashKey, item, rangeKey, validItem, validate } from './decorators'; export type KeyValue = string | number | Buffer | boolean | null; type SimpleKey = KeyValue; @@ -34,10 +35,13 @@ const isComposite = (hashKeys_compositeKeys: Keys): hashKeys_compositeKeys is Co export default abstract class Model { protected tableName: string | undefined; + @item protected item: T | undefined; + @hashKey protected pk: string | undefined; + @rangeKey protected sk: string | undefined; protected documentClient: DynamoDBDocumentClient; @@ -188,13 +192,14 @@ export default abstract class Model { */ async save(item: T, options?: Partial): Promise; + @validate async save( - item_options?: T | Partial, + @validItem item_options?: T | Partial, options?: Partial, ): Promise { // Handle typescript method overloading const toSave: T | undefined = - item_options != null && this.isItem(item_options) ? item_options : this.item; + item_options != null ? item_options as T : this.item; const putOptions: Partial | undefined = item_options != null && this.isItem(item_options) ? options diff --git a/src/decorators.ts b/src/decorators.ts new file mode 100644 index 0000000..5e2a172 --- /dev/null +++ b/src/decorators.ts @@ -0,0 +1,98 @@ +import "reflect-metadata"; +import joi from 'joi'; +import ValidationError from "./validation-error"; + +const itemParamMetadataKey = Symbol("ItemParam"); +const itemPropertyKey = Symbol("ItemProperty"); +const pkKey = Symbol('PK'); +const skKey = Symbol('SK'); + +export function hashKey(target: any, key: string) { + Reflect.defineProperty(target, key, { + get: function () { + return this[pkKey]; + }, + set: function (newVal: string) { + this[pkKey] = newVal + } + }) +} + +export function rangeKey(target: any, key: string) { + Reflect.defineProperty(target, key, { + get: function () { + return this[skKey]; + }, + set: function (newVal: string) { + this[skKey] = newVal + } + }) +} + +export function item(target: any, key: string) { + Reflect.defineProperty(target, key, { + get: function () { + const pk = this[pkKey] + const sk = this[skKey] + const item = this[itemPropertyKey]; + validateSchema(pk, sk, item); + return item; + }, + set: function (newVal: unknown) { + this[itemPropertyKey] = newVal; + } + }) +} + +export function validItem( + target: any, + propertyKey: string, + parameterIndex: number +) { + const existingParameters: number[] = + Reflect.getOwnMetadata(itemParamMetadataKey, target, propertyKey) || []; + existingParameters.push(parameterIndex); + Reflect.defineMetadata( + itemParamMetadataKey, + existingParameters, + target, + propertyKey + ); +} + +export function validate( + target: any, + propertyName: string, + descriptor: any +) { + const original = descriptor.value; + descriptor.value = function (...args: any[]) { + const pk = this[pkKey] + const sk = this[skKey] + const parameters: number[] = Reflect.getOwnMetadata( + itemParamMetadataKey, + target, + propertyName + ); + if (parameters) { + for (const parameter of parameters) { + validateSchema(pk, sk, args[parameter]); + } + } + return original.apply(this, args); + }; +} + +function validateSchema(pk: string | undefined, sk: string | undefined, item: unknown) { + if (!pk) { + throw new Error('Model error: hash key is not defined on this Model'); + } + const schema = joi.object().keys({ + [pk]: joi.required(), + ...(!!sk && { [sk]: joi.required() }) + }).options({ allowUnknown: true }) + const { error } = schema.validate(item); + if (error) { + throw new ValidationError('Validation error', error); + } +} \ No newline at end of file diff --git a/test/save.spec.ts b/test/save.spec.ts index a0f0f85..daadff9 100644 --- a/test/save.spec.ts +++ b/test/save.spec.ts @@ -1,7 +1,8 @@ import { clearTables } from './hooks/create-tables'; -import HashKeyModel from './models/hashkey'; +import HashKeyModel, { HashKeyEntity } from './models/hashkey'; import TimeTrackedModel from './models/autoCreatedAt-autoUpdatedAt'; import HashKeyJoiModel from './models/hashkey-joi'; +import CompositeKeyModel, { CompositeKeyEntity } from './models/composite-keys'; describe('The save method', () => { beforeEach(async () => { @@ -64,6 +65,41 @@ describe('The save method', () => { expect((e as Error).message.includes('No item to save')).toBe(true); } }); + test('should throw an error if the hash keys is missing', async () => { + const foo = new HashKeyModel(); + const item = { + string: 'whatever', + stringmap: { foo: 'bar' }, + stringset: ['bar, bar'], + number: 43, + bool: true, + list: ['foo', 42], + }; + try { + await foo.save(item as unknown as HashKeyEntity); + fail('should throw'); + } catch (e) { + expect((e as Error).message.includes('Validation error')).toBe(true); + } + }); + test('should throw an error if the range key is missing', async () => { + const foo = new CompositeKeyModel(); + const item = { + hashkey: 'bar', + string: 'whatever', + stringmap: { foo: 'bar' }, + stringset: ['bar, bar'], + number: 43, + bool: true, + list: ['foo', 42], + }; + try { + await foo.save(item as unknown as CompositeKeyEntity); + fail('should throw'); + } catch (e) { + expect((e as Error).message.includes('Validation error')).toBe(true); + } + }); test('should throw an error if a Joi schema is specified and validation failed', async () => { const foo = new HashKeyJoiModel({ hashkey: 'bar', From e828fd9b8b239d7091ef5855354f16c6a14ce67d Mon Sep 17 00:00:00 2001 From: Hamza Sajid Date: Wed, 31 Jan 2024 15:41:47 +0100 Subject: [PATCH 2/2] fix(test & build): add missing dependency --- package-lock.json | 7 +++++++ package.json | 1 + 2 files changed, 8 insertions(+) diff --git a/package-lock.json b/package-lock.json index d58c8f6..cb739a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "jest": "^29.6.1", "lint-staged": "^13.2.3", "prettier": "^3.0.0", + "reflect-metadata": "^0.2.1", "semantic-release": "^21.0.7", "ts-jest": "^29.1.1", "ts-node": "^10.9.1", @@ -14772,6 +14773,12 @@ "esprima": "~4.0.0" } }, + "node_modules/reflect-metadata": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.1.tgz", + "integrity": "sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==", + "dev": true + }, "node_modules/registry-auth-token": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.0.2.tgz", diff --git a/package.json b/package.json index c8b46da..16412aa 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "jest": "^29.6.1", "lint-staged": "^13.2.3", "prettier": "^3.0.0", + "reflect-metadata": "^0.2.1", "semantic-release": "^21.0.7", "ts-jest": "^29.1.1", "ts-node": "^10.9.1",