Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});