From 9af077a058c89a5fcf9454893b08071bba89c948 Mon Sep 17 00:00:00 2001 From: Vatsal Goel <144617902+VatsalGoel3@users.noreply.github.com> Date: Sat, 1 Mar 2025 00:25:09 -0700 Subject: [PATCH 1/6] feat(validation): add @validator decorator for JSON Schema validation --- packages/validation/src/decorator.ts | 79 ++++++++++++++++ .../validation/tests/unit/decorator.test.ts | 90 +++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 packages/validation/src/decorator.ts create mode 100644 packages/validation/tests/unit/decorator.test.ts diff --git a/packages/validation/src/decorator.ts b/packages/validation/src/decorator.ts new file mode 100644 index 0000000000..d92c8bf407 --- /dev/null +++ b/packages/validation/src/decorator.ts @@ -0,0 +1,79 @@ +import type { Ajv } from 'ajv'; +import { SchemaValidationError } from './errors.js'; +import { validate } from './validate.js'; +export interface ValidatorOptions { + inboundSchema?: object; + outboundSchema?: object; + envelope?: string; + formats?: Record< + string, + | string + | RegExp + | { + type?: 'string' | 'number'; + validate: (data: string) => boolean; + async?: boolean; + } + >; + externalRefs?: object[]; + ajv?: Ajv; +} + +type AsyncMethod = (...args: unknown[]) => Promise; + +export function validator(options: ValidatorOptions): MethodDecorator { + return ( + _target, + _propertyKey, + descriptor: TypedPropertyDescriptor + ) => { + if (!descriptor.value) { + return descriptor; + } + + if (!options.inboundSchema && !options.outboundSchema) { + return descriptor; + } + + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: unknown[]): Promise { + let validatedInput = args[0]; + + if (options.inboundSchema) { + try { + validatedInput = validate({ + payload: args[0], + schema: options.inboundSchema, + envelope: options.envelope, + formats: options.formats, + externalRefs: options.externalRefs, + ajv: options.ajv, + }); + } catch (error) { + throw new SchemaValidationError('Inbound validation failed', error); + } + } + + const result = await originalMethod.apply(this, [ + validatedInput, + ...args.slice(1), + ]); + if (options.outboundSchema) { + try { + return validate({ + payload: result, + schema: options.outboundSchema, + formats: options.formats, + externalRefs: options.externalRefs, + ajv: options.ajv, + }); + } catch (error) { + throw new SchemaValidationError('Outbound Validation failed', error); + } + } + return result; + }; + return descriptor; + }; +} diff --git a/packages/validation/tests/unit/decorator.test.ts b/packages/validation/tests/unit/decorator.test.ts new file mode 100644 index 0000000000..3af81ee76f --- /dev/null +++ b/packages/validation/tests/unit/decorator.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { validator } from '../../src/decorator.js'; +import { SchemaValidationError } from '../../src/errors.js'; + +const inboundSchema = { + type: 'object', + properties: { + value: { type: 'number' }, + }, + required: ['value'], + additionalProperties: false, +}; + +const outboundSchema = { + type: 'object', + properties: { + result: { type: 'number' }, + }, + required: ['result'], + additionalProperties: false, +}; + +class TestClass { + @validator({ inboundSchema, outboundSchema }) + async multiply(input: { value: number }): Promise<{ result: number }> { + return { result: input.value * 2 }; + } +} + +describe('validator decorator', () => { + it('should validate inbound and outbound successfully', async () => { + // Prepare + const instance = new TestClass(); + const input = { value: 5 }; + + // Act + const output = await instance.multiply(input); + + // Assess + expect(output).toEqual({ result: 10 }); + }); + + it('should throw error on inbound validation failure', async () => { + // Prepare + const instance = new TestClass(); + const invalidInput = { value: 'not a number' } as unknown as { + value: number; + }; + + // Act & Assess + await expect(instance.multiply(invalidInput)).rejects.toThrow( + SchemaValidationError + ); + }); + + it('should throw error on outbound validation failure', async () => { + // Prepare + class TestClassInvalid { + @validator({ inboundSchema, outboundSchema }) + async multiply(input: { value: number }): Promise<{ result: number }> { + return { result: 'invalid' } as unknown as { result: number }; + } + } + const instance = new TestClassInvalid(); + const input = { value: 5 }; + + // Act & Assess + await expect(instance.multiply(input)).rejects.toThrow( + SchemaValidationError + ); + }); + + it('should no-op when no schemas are provided', async () => { + // Prepare + class TestClassNoOp { + @validator({}) + async echo(input: unknown): Promise { + return input; + } + } + const instance = new TestClassNoOp(); + const data = { foo: 'bar' }; + + // Act + const result = await instance.echo(data); + + // Assess + expect(result).toEqual(data); + }); +}); From 4e0ea04414a747fb3f2adfa087937ca7716349ca Mon Sep 17 00:00:00 2001 From: Vatsal Goel <144617902+VatsalGoel3@users.noreply.github.com> Date: Sat, 1 Mar 2025 00:36:53 -0700 Subject: [PATCH 2/6] Updated the test suite --- .../validation/tests/unit/decorator.test.ts | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/validation/tests/unit/decorator.test.ts b/packages/validation/tests/unit/decorator.test.ts index 3af81ee76f..127f1db1c8 100644 --- a/packages/validation/tests/unit/decorator.test.ts +++ b/packages/validation/tests/unit/decorator.test.ts @@ -20,33 +20,35 @@ const outboundSchema = { additionalProperties: false, }; -class TestClass { - @validator({ inboundSchema, outboundSchema }) - async multiply(input: { value: number }): Promise<{ result: number }> { - return { result: input.value * 2 }; - } -} - describe('validator decorator', () => { it('should validate inbound and outbound successfully', async () => { // Prepare + class TestClass { + @validator({ inboundSchema, outboundSchema }) + async multiply(input: { value: number }): Promise<{ result: number }> { + return { result: input.value * 2 }; + } + } const instance = new TestClass(); const input = { value: 5 }; - // Act const output = await instance.multiply(input); - // Assess expect(output).toEqual({ result: 10 }); }); it('should throw error on inbound validation failure', async () => { // Prepare + class TestClass { + @validator({ inboundSchema, outboundSchema }) + async multiply(input: { value: number }): Promise<{ result: number }> { + return { result: input.value * 2 }; + } + } const instance = new TestClass(); const invalidInput = { value: 'not a number' } as unknown as { value: number; }; - // Act & Assess await expect(instance.multiply(invalidInput)).rejects.toThrow( SchemaValidationError @@ -63,7 +65,6 @@ describe('validator decorator', () => { } const instance = new TestClassInvalid(); const input = { value: 5 }; - // Act & Assess await expect(instance.multiply(input)).rejects.toThrow( SchemaValidationError @@ -80,11 +81,22 @@ describe('validator decorator', () => { } const instance = new TestClassNoOp(); const data = { foo: 'bar' }; - // Act const result = await instance.echo(data); - // Assess expect(result).toEqual(data); }); + + it('should return descriptor unmodified if descriptor.value is undefined', () => { + // Prepare + const descriptor: PropertyDescriptor = {}; + // Act + const result = validator({ inboundSchema })( + null as unknown, + 'testMethod', + descriptor + ); + // Assess + expect(result).toEqual(descriptor); + }); }); From 4984867d3e8f94346b0362928fc6b064cdbc1ea4 Mon Sep 17 00:00:00 2001 From: Vatsal Goel <144617902+VatsalGoel3@users.noreply.github.com> Date: Sat, 1 Mar 2025 00:39:08 -0700 Subject: [PATCH 3/6] updated test suite --- .../validation/tests/unit/decorator.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/validation/tests/unit/decorator.test.ts b/packages/validation/tests/unit/decorator.test.ts index 127f1db1c8..8e0f5a06d3 100644 --- a/packages/validation/tests/unit/decorator.test.ts +++ b/packages/validation/tests/unit/decorator.test.ts @@ -99,4 +99,36 @@ describe('validator decorator', () => { // Assess expect(result).toEqual(descriptor); }); + + it('should validate inbound only', async () => { + // Prepare + class TestClassInbound { + @validator({ inboundSchema }) + async process(input: { value: number }): Promise<{ data: string }> { + return { data: JSON.stringify(input) }; + } + } + const instance = new TestClassInbound(); + const input = { value: 10 }; + // Act + const output = await instance.process(input); + // Assess + expect(output).toEqual({ data: JSON.stringify(input) }); + }); + + it('should validate outbound only', async () => { + // Prepare + class TestClassOutbound { + @validator({ outboundSchema }) + async process(input: { text: string }): Promise<{ result: number }> { + return { result: 42 }; + } + } + const instance = new TestClassOutbound(); + const input = { text: 'hello' }; + // Act + const output = await instance.process(input); + // Assess + expect(output).toEqual({ result: 42 }); + }); }); From a4f1060838e218d4395eb2b3245c56aa31ff89fb Mon Sep 17 00:00:00 2001 From: Vatsal Goel <144617902+VatsalGoel3@users.noreply.github.com> Date: Mon, 3 Mar 2025 08:42:10 -0700 Subject: [PATCH 4/6] refactor(validation): update decorator with improved types and schema validation --- packages/validation/src/decorator.ts | 26 ++------------ packages/validation/src/types.ts | 36 ++++++++++++++----- .../validation/tests/unit/decorator.test.ts | 2 +- 3 files changed, 30 insertions(+), 34 deletions(-) diff --git a/packages/validation/src/decorator.ts b/packages/validation/src/decorator.ts index d92c8bf407..6232542ed7 100644 --- a/packages/validation/src/decorator.ts +++ b/packages/validation/src/decorator.ts @@ -1,23 +1,6 @@ -import type { Ajv } from 'ajv'; import { SchemaValidationError } from './errors.js'; +import type { ValidatorOptions } from './types.js'; import { validate } from './validate.js'; -export interface ValidatorOptions { - inboundSchema?: object; - outboundSchema?: object; - envelope?: string; - formats?: Record< - string, - | string - | RegExp - | { - type?: 'string' | 'number'; - validate: (data: string) => boolean; - async?: boolean; - } - >; - externalRefs?: object[]; - ajv?: Ajv; -} type AsyncMethod = (...args: unknown[]) => Promise; @@ -30,16 +13,12 @@ export function validator(options: ValidatorOptions): MethodDecorator { if (!descriptor.value) { return descriptor; } - if (!options.inboundSchema && !options.outboundSchema) { return descriptor; } - const originalMethod = descriptor.value; - descriptor.value = async function (...args: unknown[]): Promise { let validatedInput = args[0]; - if (options.inboundSchema) { try { validatedInput = validate({ @@ -54,7 +33,6 @@ export function validator(options: ValidatorOptions): MethodDecorator { throw new SchemaValidationError('Inbound validation failed', error); } } - const result = await originalMethod.apply(this, [ validatedInput, ...args.slice(1), @@ -69,7 +47,7 @@ export function validator(options: ValidatorOptions): MethodDecorator { ajv: options.ajv, }); } catch (error) { - throw new SchemaValidationError('Outbound Validation failed', error); + throw new SchemaValidationError('Outbound validation failed', error); } } return result; diff --git a/packages/validation/src/types.ts b/packages/validation/src/types.ts index fd1efbaab0..4543e6ffe9 100644 --- a/packages/validation/src/types.ts +++ b/packages/validation/src/types.ts @@ -1,18 +1,36 @@ -import type Ajv from 'ajv'; -export interface ValidateParams { +import type { + Ajv, + AnySchema, + AsyncFormatDefinition, + FormatDefinition, +} from 'ajv'; + +type Prettify = { + [K in keyof T]: T[K]; +} & {}; + +type ValidateParams = { payload: unknown; - schema: object; + schema: AnySchema; envelope?: string; formats?: Record< string, | string | RegExp - | { - type?: 'string' | 'number'; - validate: (data: string) => boolean; - async?: boolean; - } + | FormatDefinition + | FormatDefinition + | AsyncFormatDefinition + | AsyncFormatDefinition >; externalRefs?: object[]; ajv?: Ajv; -} +}; + +type ValidatorOptions = Prettify< + Omit & { + inboundSchema?: AnySchema; + outboundSchema?: AnySchema; + } +>; + +export type { ValidateParams, ValidatorOptions }; diff --git a/packages/validation/tests/unit/decorator.test.ts b/packages/validation/tests/unit/decorator.test.ts index 8e0f5a06d3..94ba2f0c40 100644 --- a/packages/validation/tests/unit/decorator.test.ts +++ b/packages/validation/tests/unit/decorator.test.ts @@ -92,7 +92,7 @@ describe('validator decorator', () => { const descriptor: PropertyDescriptor = {}; // Act const result = validator({ inboundSchema })( - null as unknown, + null as unknown as object, 'testMethod', descriptor ); From 8676b00a9bf39a06247d4a6b4c862c1d6346898e Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Tue, 4 Mar 2025 09:36:01 +0100 Subject: [PATCH 5/6] Update packages/validation/src/decorator.ts --- packages/validation/src/decorator.ts | 51 +++++++++++++++------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/validation/src/decorator.ts b/packages/validation/src/decorator.ts index 6232542ed7..b55e5159ad 100644 --- a/packages/validation/src/decorator.ts +++ b/packages/validation/src/decorator.ts @@ -1,33 +1,38 @@ import { SchemaValidationError } from './errors.js'; -import type { ValidatorOptions } from './types.js'; import { validate } from './validate.js'; - -type AsyncMethod = (...args: unknown[]) => Promise; - -export function validator(options: ValidatorOptions): MethodDecorator { +import type { ValidatorOptions } from './types.js'; +export function validator(options: ValidatorOptions) { return ( - _target, - _propertyKey, - descriptor: TypedPropertyDescriptor + _target: unknown, + _propertyKey: string | symbol, + descriptor: PropertyDescriptor ) => { if (!descriptor.value) { return descriptor; } - if (!options.inboundSchema && !options.outboundSchema) { + const { + inboundSchema, + outboundSchema, + envelope, + formats, + externalRefs, + ajv, + } = options; + if (!inboundSchema && !outboundSchema) { return descriptor; } const originalMethod = descriptor.value; - descriptor.value = async function (...args: unknown[]): Promise { + descriptor.value = async function (...args: unknown[]) { let validatedInput = args[0]; - if (options.inboundSchema) { + if (inboundSchema) { try { validatedInput = validate({ - payload: args[0], - schema: options.inboundSchema, - envelope: options.envelope, - formats: options.formats, - externalRefs: options.externalRefs, - ajv: options.ajv, + payload: validatedInput, + schema: inboundSchema, + envelope: envelope, + formats: formats, + externalRefs: externalRefs, + ajv: ajv, }); } catch (error) { throw new SchemaValidationError('Inbound validation failed', error); @@ -37,17 +42,17 @@ export function validator(options: ValidatorOptions): MethodDecorator { validatedInput, ...args.slice(1), ]); - if (options.outboundSchema) { + if (outboundSchema) { try { return validate({ payload: result, - schema: options.outboundSchema, - formats: options.formats, - externalRefs: options.externalRefs, - ajv: options.ajv, + schema: outboundSchema, + formats: formats, + externalRefs: externalRefs, + ajv: ajv, }); } catch (error) { - throw new SchemaValidationError('Outbound validation failed', error); + throw new SchemaValidationError('Outbound Validation failed', error); } } return result; From e93f513870f8c717415c15237e10f8053459a9d2 Mon Sep 17 00:00:00 2001 From: Vatsal Goel <144617902+VatsalGoel3@users.noreply.github.com> Date: Tue, 4 Mar 2025 01:47:56 -0700 Subject: [PATCH 6/6] Updated imports and exports --- packages/validation/src/index.ts | 3 ++- packages/validation/src/validate.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 093ce43dd5..039a9236fa 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1,2 +1,3 @@ -export { validate } from './validate'; +export { validate } from './validate.js'; export { SchemaValidationError } from './errors.js'; +export { validator } from './decorator.js'; diff --git a/packages/validation/src/validate.ts b/packages/validation/src/validate.ts index 75e2af96af..36d0fccb0a 100644 --- a/packages/validation/src/validate.ts +++ b/packages/validation/src/validate.ts @@ -1,9 +1,9 @@ import { search } from '@aws-lambda-powertools/jmespath'; -import Ajv, { type ValidateFunction } from 'ajv'; +import { Ajv, type ValidateFunction } from 'ajv'; import { SchemaValidationError } from './errors.js'; import type { ValidateParams } from './types.js'; -export function validate(params: ValidateParams): T { +export function validate(params: ValidateParams): T { const { payload, schema, envelope, formats, externalRefs, ajv } = params; const ajvInstance = ajv || new Ajv({ allErrors: true });