diff --git a/packages/validation/src/errors.ts b/packages/validation/src/errors.ts new file mode 100644 index 0000000000..db41e7f7e5 --- /dev/null +++ b/packages/validation/src/errors.ts @@ -0,0 +1,9 @@ +export class SchemaValidationError extends Error { + public errors: unknown; + + constructor(message: string, errors?: unknown) { + super(message); + this.name = 'SchemaValidationError'; + this.errors = errors; + } +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 9d87720cc5..093ce43dd5 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1 +1,2 @@ -export const foo = () => true; +export { validate } from './validate'; +export { SchemaValidationError } from './errors.js'; diff --git a/packages/validation/src/types.ts b/packages/validation/src/types.ts new file mode 100644 index 0000000000..fd1efbaab0 --- /dev/null +++ b/packages/validation/src/types.ts @@ -0,0 +1,18 @@ +import type Ajv from 'ajv'; +export interface ValidateParams { + payload: unknown; + schema: object; + envelope?: string; + formats?: Record< + string, + | string + | RegExp + | { + type?: 'string' | 'number'; + validate: (data: string) => boolean; + async?: boolean; + } + >; + externalRefs?: object[]; + ajv?: Ajv; +} diff --git a/packages/validation/src/validate.ts b/packages/validation/src/validate.ts new file mode 100644 index 0000000000..75e2af96af --- /dev/null +++ b/packages/validation/src/validate.ts @@ -0,0 +1,43 @@ +import { search } from '@aws-lambda-powertools/jmespath'; +import Ajv, { type ValidateFunction } from 'ajv'; +import { SchemaValidationError } from './errors.js'; +import type { ValidateParams } from './types.js'; + +export function validate(params: ValidateParams): T { + const { payload, schema, envelope, formats, externalRefs, ajv } = params; + const ajvInstance = ajv || new Ajv({ allErrors: true }); + + if (formats) { + for (const key of Object.keys(formats)) { + ajvInstance.addFormat(key, formats[key]); + } + } + + if (externalRefs) { + for (const refSchema of externalRefs) { + ajvInstance.addSchema(refSchema); + } + } + + let validateFn: ValidateFunction; + try { + validateFn = ajvInstance.compile(schema); + } catch (error) { + throw new SchemaValidationError('Failed to compile schema', error); + } + + const trimmedEnvelope = envelope?.trim(); + const dataToValidate = trimmedEnvelope + ? search(trimmedEnvelope, payload as Record) + : payload; + + const valid = validateFn(dataToValidate); + if (!valid) { + throw new SchemaValidationError( + 'Schema validation failed', + validateFn.errors + ); + } + + return dataToValidate as T; +} diff --git a/packages/validation/tests/unit/index.test.ts b/packages/validation/tests/unit/index.test.ts index 096d56dbb9..e2af63bd92 100644 --- a/packages/validation/tests/unit/index.test.ts +++ b/packages/validation/tests/unit/index.test.ts @@ -1,16 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { foo } from '../../src/index.js'; +import { SchemaValidationError, validate } from '../../src/index.js'; -describe('Validation', () => { +describe('Index exports', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('should return true', () => { - // Act - const result = foo(); + it('should export validate as a function', () => { + // Act & Assess + expect(typeof validate).toBe('function'); + }); - // Assess - expect(result).toBe(true); + it('should export SchemaValidationError as a function', () => { + // Act & Assess + expect(typeof SchemaValidationError).toBe('function'); }); }); diff --git a/packages/validation/tests/unit/validate.test.ts b/packages/validation/tests/unit/validate.test.ts new file mode 100644 index 0000000000..b4480f580e --- /dev/null +++ b/packages/validation/tests/unit/validate.test.ts @@ -0,0 +1,167 @@ +import Ajv from 'ajv'; +import { describe, expect, it } from 'vitest'; +import { SchemaValidationError } from '../../src/errors.js'; +import type { ValidateParams } from '../../src/types.js'; +import { validate } from '../../src/validate.js'; + +describe('validate function', () => { + it('returns validated data when payload is valid', () => { + // Prepare + const payload = { name: 'John', age: 30 }; + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + additionalProperties: false, + }; + + const params: ValidateParams = { payload, schema }; + + // Act + const result = validate(params); + + // Assess + expect(result).toEqual(payload); + }); + + it('throws SchemaValidationError when payload is invalid', () => { + // Prepare + const payload = { name: 'John', age: '30' }; + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + additionalProperties: false, + }; + + const params: ValidateParams = { payload, schema }; + + // Act & Assess + expect(() => validate(params)).toThrow(SchemaValidationError); + }); + + it('extracts data using envelope when provided', () => { + // Prepare + const payload = { + data: { + user: { name: 'Alice', age: 25 }, + }, + }; + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + additionalProperties: false, + }; + + const envelope = 'data.user'; + const params: ValidateParams = { payload, schema, envelope }; + + // Act + const result = validate(params); + + // Assess + expect(result).toEqual({ name: 'Alice', age: 25 }); + }); + + it('uses provided ajv instance and custom formats', () => { + // Prepare + const payload = { email: 'test@example.com' }; + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'custom-email' }, + }, + required: ['email'], + additionalProperties: false, + }; + + const ajvInstance = new Ajv({ allErrors: true }); + const formats = { + 'custom-email': { + type: 'string', + validate: (email: string) => email.includes('@'), + }, + }; + + const params: ValidateParams = { + payload, + schema, + ajv: ajvInstance, + formats, + }; + + // Act + const result = validate(params); + + // Assess + expect(result).toEqual(payload); + }); + + it('adds external schemas to ajv instance when provided', () => { + // Prepare + const externalSchema = { + $id: 'http://example.com/schemas/address.json', + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' }, + }, + required: ['street', 'city'], + additionalProperties: false, + }; + + const schema = { + type: 'object', + properties: { + address: { $ref: 'http://example.com/schemas/address.json' }, + }, + required: ['address'], + additionalProperties: false, + }; + + const payload = { + address: { + street: '123 Main St', + city: 'Metropolis', + }, + }; + + const params: ValidateParams = { + payload, + schema, + externalRefs: [externalSchema], + }; + + // Act + const result = validate(params); + + // Assess + expect(result).toEqual(payload); + }); + + it('throws SchemaValidationError when schema compilation fails', () => { + // Prepare + const payload = { name: 'John' }; + const schema = { + type: 'object', + properties: { + name: { type: 'invalid-type' }, + }, + }; + + const params: ValidateParams = { payload, schema }; + + // Act & Assess + expect(() => validate(params)).toThrow(SchemaValidationError); + }); +});