diff --git a/packages/validation/src/decorator.ts b/packages/validation/src/decorator.ts new file mode 100644 index 0000000000..45a9edfb3d --- /dev/null +++ b/packages/validation/src/decorator.ts @@ -0,0 +1,62 @@ +import { SchemaValidationError } from './errors.js'; +import type { ValidatorOptions } from './types.js'; +import { validate } from './validate.js'; +export function validator(options: ValidatorOptions) { + return ( + _target: unknown, + _propertyKey: string | symbol, + descriptor: PropertyDescriptor + ) => { + if (!descriptor.value) { + return descriptor; + } + const { + inboundSchema, + outboundSchema, + envelope, + formats, + externalRefs, + ajv, + } = options; + if (!inboundSchema && !outboundSchema) { + return descriptor; + } + const originalMethod = descriptor.value; + descriptor.value = async function (...args: unknown[]) { + let validatedInput = args[0]; + if (inboundSchema) { + try { + validatedInput = validate({ + payload: validatedInput, + schema: inboundSchema, + envelope: envelope, + formats: formats, + externalRefs: externalRefs, + ajv: ajv, + }); + } catch (error) { + throw new SchemaValidationError('Inbound validation failed', error); + } + } + const result = await originalMethod.apply(this, [ + validatedInput, + ...args.slice(1), + ]); + if (outboundSchema) { + try { + return validate({ + payload: result, + schema: outboundSchema, + formats: formats, + externalRefs: externalRefs, + ajv: ajv, + }); + } catch (error) { + throw new SchemaValidationError('Outbound Validation failed', error); + } + } + return result; + }; + return descriptor; + }; +} 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/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/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 }); diff --git a/packages/validation/tests/unit/decorator.test.ts b/packages/validation/tests/unit/decorator.test.ts new file mode 100644 index 0000000000..94ba2f0c40 --- /dev/null +++ b/packages/validation/tests/unit/decorator.test.ts @@ -0,0 +1,134 @@ +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, +}; + +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 + ); + }); + + 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); + }); + + it('should return descriptor unmodified if descriptor.value is undefined', () => { + // Prepare + const descriptor: PropertyDescriptor = {}; + // Act + const result = validator({ inboundSchema })( + null as unknown as object, + 'testMethod', + descriptor + ); + // 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 }); + }); +});