Skip to content

feat(validation): implement validate function and SchemaValidationErr… #3662

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 27, 2025
9 changes: 9 additions & 0 deletions packages/validation/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions packages/validation/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const foo = () => true;
export { validate } from './validate';
export { SchemaValidationError } from './errors';
15 changes: 15 additions & 0 deletions packages/validation/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type Ajv from 'ajv';

export interface ValidateParams<T = unknown> {
payload: unknown;
schema: object;
envelope?: string;
formats?: Record<
string,
| string
| RegExp
| { type?: string; validate: (data: string) => boolean; async?: boolean }
>;
externalRefs?: object[];
ajv?: Ajv;
}
53 changes: 53 additions & 0 deletions packages/validation/src/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { search } from '@aws-lambda-powertools/jmespath'; // Use default export
import Ajv, { type ValidateFunction } from 'ajv';
import { SchemaValidationError } from './errors';
import type { ValidateParams } from './types';

export function validate<T = unknown>(params: ValidateParams<T>): 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);
}
}

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<string, unknown>)
: payload;

const valid = validateFn(dataToValidate);
if (!valid) {
throw new SchemaValidationError(
'Schema validation failed',
validateFn.errors
);
}

return dataToValidate as T;
}
168 changes: 168 additions & 0 deletions packages/validation/tests/unit/validate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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';

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<typeof payload> = { payload, schema };

// Act
const result = validate<typeof payload>(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: '[email protected]' };
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
// 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);
});
});