From 6046e54e32ec46a3d8085765328327c4d477da5e Mon Sep 17 00:00:00 2001 From: Vatsal Goel <144617902+VatsalGoel3@users.noreply.github.com> Date: Mon, 24 Feb 2025 22:52:21 -0700 Subject: [PATCH 1/4] feat(validation): implement validate function and SchemaValidationError and add comprehensive unit tests for validate function --- .../validation/src/SchemaValidationError.ts | 9 ++ packages/validation/src/index.ts | 2 + packages/validation/src/validate.ts | 55 +++++++ .../validation/tests/unit/validate.test.ts | 151 ++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 packages/validation/src/SchemaValidationError.ts create mode 100644 packages/validation/src/validate.ts create mode 100644 packages/validation/tests/unit/validate.test.ts diff --git a/packages/validation/src/SchemaValidationError.ts b/packages/validation/src/SchemaValidationError.ts new file mode 100644 index 0000000000..db41e7f7e5 --- /dev/null +++ b/packages/validation/src/SchemaValidationError.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..80c00eaf7b 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1 +1,3 @@ export const foo = () => true; +export * from './validate'; +export * from './SchemaValidationError'; diff --git a/packages/validation/src/validate.ts b/packages/validation/src/validate.ts new file mode 100644 index 0000000000..db65288e7b --- /dev/null +++ b/packages/validation/src/validate.ts @@ -0,0 +1,55 @@ +import { search } from '@aws-lambda-powertools/jmespath'; +import Ajv, { type ValidateFunction } from 'ajv'; +import { SchemaValidationError } from './SchemaValidationError'; + +export interface ValidateParams { + payload: unknown; + schema: object; + envelope?: string; + formats?: Record< + string, + string | RegExp | { type?: string; validate: (data: string) => boolean } + >; + externalRefs?: object[]; + ajv?: Ajv; +} + +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/validate.test.ts b/packages/validation/tests/unit/validate.test.ts new file mode 100644 index 0000000000..e98917f299 --- /dev/null +++ b/packages/validation/tests/unit/validate.test.ts @@ -0,0 +1,151 @@ +import Ajv from 'ajv'; +import { describe, expect, it } from 'vitest'; +import { SchemaValidationError } from '../../src/SchemaValidationError'; +import { type ValidateParams, validate } from '../../src/validate'; + +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); + }); +}); From 47ad4803852769e278ae9fbca515c33a41b7eeda Mon Sep 17 00:00:00 2001 From: Vatsal Goel <144617902+VatsalGoel3@users.noreply.github.com> Date: Wed, 26 Feb 2025 11:57:06 -0700 Subject: [PATCH 2/4] refactor(validation): Extract types, rename errors.ts, add schema compilation test --- .../{SchemaValidationError.ts => errors.ts} | 0 packages/validation/src/index.ts | 4 +-- packages/validation/src/types.ts | 15 ++++++++++ packages/validation/src/validate.ts | 28 +++++++++---------- .../validation/tests/unit/validate.test.ts | 23 +++++++++++++-- 5 files changed, 50 insertions(+), 20 deletions(-) rename packages/validation/src/{SchemaValidationError.ts => errors.ts} (100%) create mode 100644 packages/validation/src/types.ts diff --git a/packages/validation/src/SchemaValidationError.ts b/packages/validation/src/errors.ts similarity index 100% rename from packages/validation/src/SchemaValidationError.ts rename to packages/validation/src/errors.ts diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 80c00eaf7b..9429132023 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1,3 +1,3 @@ export const foo = () => true; -export * from './validate'; -export * from './SchemaValidationError'; +export { validate } from './validate'; +export { SchemaValidationError } from './errors'; diff --git a/packages/validation/src/types.ts b/packages/validation/src/types.ts new file mode 100644 index 0000000000..cf9ac9ccd4 --- /dev/null +++ b/packages/validation/src/types.ts @@ -0,0 +1,15 @@ +import type Ajv from 'ajv'; + +export interface ValidateParams { + payload: unknown; + schema: object; + envelope?: string; + formats?: Record< + string, + | string + | RegExp + | { type?: string; validate: (data: string) => boolean; async?: boolean } + >; + externalRefs?: object[]; + ajv?: Ajv; +} diff --git a/packages/validation/src/validate.ts b/packages/validation/src/validate.ts index db65288e7b..8630770b45 100644 --- a/packages/validation/src/validate.ts +++ b/packages/validation/src/validate.ts @@ -1,18 +1,7 @@ -import { search } from '@aws-lambda-powertools/jmespath'; +import { search } from '@aws-lambda-powertools/jmespath'; // Use default export import Ajv, { type ValidateFunction } from 'ajv'; -import { SchemaValidationError } from './SchemaValidationError'; - -export interface ValidateParams { - payload: unknown; - schema: object; - envelope?: string; - formats?: Record< - string, - string | RegExp | { type?: string; validate: (data: string) => boolean } - >; - externalRefs?: object[]; - ajv?: Ajv; -} +import { SchemaValidationError } from './errors'; +import type { ValidateParams } from './types'; export function validate(params: ValidateParams): T { const { payload, schema, envelope, formats, externalRefs, ajv } = params; @@ -21,7 +10,16 @@ export function validate(params: ValidateParams): T { if (formats) { for (const key of Object.keys(formats)) { - ajvInstance.addFormat(key, formats[key]); + let formatDefinition = formats[key]; + if ( + typeof formatDefinition === 'object' && + formatDefinition !== null && + !(formatDefinition instanceof RegExp) && + !('async' in formatDefinition) + ) { + formatDefinition = { ...formatDefinition, async: false }; + } + ajvInstance.addFormat(key, formatDefinition); } } diff --git a/packages/validation/tests/unit/validate.test.ts b/packages/validation/tests/unit/validate.test.ts index e98917f299..5047f185c4 100644 --- a/packages/validation/tests/unit/validate.test.ts +++ b/packages/validation/tests/unit/validate.test.ts @@ -1,7 +1,8 @@ import Ajv from 'ajv'; import { describe, expect, it } from 'vitest'; -import { SchemaValidationError } from '../../src/SchemaValidationError'; -import { type ValidateParams, validate } from '../../src/validate'; +import { SchemaValidationError } from '../../src/errors'; +import type { ValidateParams } from '../../src/types'; +import { validate } from '../../src/validate'; describe('validate function', () => { it('returns validated data when payload is valid', () => { @@ -63,7 +64,6 @@ describe('validate function', () => { }; const envelope = 'data.user'; - const params: ValidateParams = { payload, schema, envelope }; // Act @@ -148,4 +148,21 @@ describe('validate function', () => { // Assess expect(result).toEqual(payload); }); + + it('throws SchemaValidationError when schema compilation fails', () => { + // Prepare + // An invalid schema is provided to force ajvInstance.compile() to fail. + const payload = { name: 'John' }; + const schema = { + type: 'object', + properties: { + name: { type: 'invalid-type' }, // invalid type to trigger failure + }, + }; + + const params: ValidateParams = { payload, schema }; + + // Act & Assess + expect(() => validate(params)).toThrow(SchemaValidationError); + }); }); From a2dc4d0f3c9d5cc09231f49915522d222dfc8809 Mon Sep 17 00:00:00 2001 From: Vatsal Goel <144617902+VatsalGoel3@users.noreply.github.com> Date: Wed, 26 Feb 2025 23:38:09 -0700 Subject: [PATCH 3/4] feat(corrections) --- packages/validation/src/index.ts | 1 - packages/validation/src/types.ts | 7 +++++-- packages/validation/src/validate.ts | 18 ++++-------------- packages/validation/tests/unit/index.test.ts | 16 +++++++++------- .../validation/tests/unit/validate.test.ts | 9 ++++----- 5 files changed, 22 insertions(+), 29 deletions(-) diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 9429132023..bceedf4c8c 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1,3 +1,2 @@ -export const foo = () => true; export { validate } from './validate'; export { SchemaValidationError } from './errors'; diff --git a/packages/validation/src/types.ts b/packages/validation/src/types.ts index cf9ac9ccd4..fd1efbaab0 100644 --- a/packages/validation/src/types.ts +++ b/packages/validation/src/types.ts @@ -1,5 +1,4 @@ import type Ajv from 'ajv'; - export interface ValidateParams { payload: unknown; schema: object; @@ -8,7 +7,11 @@ export interface ValidateParams { string, | string | RegExp - | { type?: string; validate: (data: string) => boolean; async?: boolean } + | { + 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 index 8630770b45..75e2af96af 100644 --- a/packages/validation/src/validate.ts +++ b/packages/validation/src/validate.ts @@ -1,25 +1,15 @@ -import { search } from '@aws-lambda-powertools/jmespath'; // Use default export +import { search } from '@aws-lambda-powertools/jmespath'; import Ajv, { type ValidateFunction } from 'ajv'; -import { SchemaValidationError } from './errors'; -import type { ValidateParams } from './types'; +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)) { - let formatDefinition = formats[key]; - if ( - typeof formatDefinition === 'object' && - formatDefinition !== null && - !(formatDefinition instanceof RegExp) && - !('async' in formatDefinition) - ) { - formatDefinition = { ...formatDefinition, async: false }; - } - ajvInstance.addFormat(key, formatDefinition); + ajvInstance.addFormat(key, formats[key]); } } 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 index 5047f185c4..b4480f580e 100644 --- a/packages/validation/tests/unit/validate.test.ts +++ b/packages/validation/tests/unit/validate.test.ts @@ -1,8 +1,8 @@ import Ajv from 'ajv'; import { describe, expect, it } from 'vitest'; -import { SchemaValidationError } from '../../src/errors'; -import type { ValidateParams } from '../../src/types'; -import { validate } from '../../src/validate'; +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', () => { @@ -151,12 +151,11 @@ describe('validate function', () => { it('throws SchemaValidationError when schema compilation fails', () => { // Prepare - // An invalid schema is provided to force ajvInstance.compile() to fail. const payload = { name: 'John' }; const schema = { type: 'object', properties: { - name: { type: 'invalid-type' }, // invalid type to trigger failure + name: { type: 'invalid-type' }, }, }; From 82421b06d344213dc026140dcaf97d1d4076191b Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Thu, 27 Feb 2025 13:49:28 +0100 Subject: [PATCH 4/4] Apply suggestions from code review --- packages/validation/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index bceedf4c8c..093ce43dd5 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1,2 +1,2 @@ export { validate } from './validate'; -export { SchemaValidationError } from './errors'; +export { SchemaValidationError } from './errors.js';