Skip to content

Commit f55127b

Browse files
feat(validation): implement validate function (#3662)
Co-authored-by: Andrea Amorosi <[email protected]>
1 parent c14c7b3 commit f55127b

File tree

6 files changed

+248
-8
lines changed

6 files changed

+248
-8
lines changed

Diff for: packages/validation/src/errors.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export class SchemaValidationError extends Error {
2+
public errors: unknown;
3+
4+
constructor(message: string, errors?: unknown) {
5+
super(message);
6+
this.name = 'SchemaValidationError';
7+
this.errors = errors;
8+
}
9+
}

Diff for: packages/validation/src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
export const foo = () => true;
1+
export { validate } from './validate';
2+
export { SchemaValidationError } from './errors.js';

Diff for: packages/validation/src/types.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type Ajv from 'ajv';
2+
export interface ValidateParams<T = unknown> {
3+
payload: unknown;
4+
schema: object;
5+
envelope?: string;
6+
formats?: Record<
7+
string,
8+
| string
9+
| RegExp
10+
| {
11+
type?: 'string' | 'number';
12+
validate: (data: string) => boolean;
13+
async?: boolean;
14+
}
15+
>;
16+
externalRefs?: object[];
17+
ajv?: Ajv;
18+
}

Diff for: packages/validation/src/validate.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { search } from '@aws-lambda-powertools/jmespath';
2+
import Ajv, { type ValidateFunction } from 'ajv';
3+
import { SchemaValidationError } from './errors.js';
4+
import type { ValidateParams } from './types.js';
5+
6+
export function validate<T = unknown>(params: ValidateParams<T>): T {
7+
const { payload, schema, envelope, formats, externalRefs, ajv } = params;
8+
const ajvInstance = ajv || new Ajv({ allErrors: true });
9+
10+
if (formats) {
11+
for (const key of Object.keys(formats)) {
12+
ajvInstance.addFormat(key, formats[key]);
13+
}
14+
}
15+
16+
if (externalRefs) {
17+
for (const refSchema of externalRefs) {
18+
ajvInstance.addSchema(refSchema);
19+
}
20+
}
21+
22+
let validateFn: ValidateFunction;
23+
try {
24+
validateFn = ajvInstance.compile(schema);
25+
} catch (error) {
26+
throw new SchemaValidationError('Failed to compile schema', error);
27+
}
28+
29+
const trimmedEnvelope = envelope?.trim();
30+
const dataToValidate = trimmedEnvelope
31+
? search(trimmedEnvelope, payload as Record<string, unknown>)
32+
: payload;
33+
34+
const valid = validateFn(dataToValidate);
35+
if (!valid) {
36+
throw new SchemaValidationError(
37+
'Schema validation failed',
38+
validateFn.errors
39+
);
40+
}
41+
42+
return dataToValidate as T;
43+
}

Diff for: packages/validation/tests/unit/index.test.ts

+9-7
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
2-
import { foo } from '../../src/index.js';
2+
import { SchemaValidationError, validate } from '../../src/index.js';
33

4-
describe('Validation', () => {
4+
describe('Index exports', () => {
55
beforeEach(() => {
66
vi.clearAllMocks();
77
});
88

9-
it('should return true', () => {
10-
// Act
11-
const result = foo();
9+
it('should export validate as a function', () => {
10+
// Act & Assess
11+
expect(typeof validate).toBe('function');
12+
});
1213

13-
// Assess
14-
expect(result).toBe(true);
14+
it('should export SchemaValidationError as a function', () => {
15+
// Act & Assess
16+
expect(typeof SchemaValidationError).toBe('function');
1517
});
1618
});

Diff for: packages/validation/tests/unit/validate.test.ts

+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import Ajv from 'ajv';
2+
import { describe, expect, it } from 'vitest';
3+
import { SchemaValidationError } from '../../src/errors.js';
4+
import type { ValidateParams } from '../../src/types.js';
5+
import { validate } from '../../src/validate.js';
6+
7+
describe('validate function', () => {
8+
it('returns validated data when payload is valid', () => {
9+
// Prepare
10+
const payload = { name: 'John', age: 30 };
11+
const schema = {
12+
type: 'object',
13+
properties: {
14+
name: { type: 'string' },
15+
age: { type: 'number' },
16+
},
17+
required: ['name', 'age'],
18+
additionalProperties: false,
19+
};
20+
21+
const params: ValidateParams<typeof payload> = { payload, schema };
22+
23+
// Act
24+
const result = validate<typeof payload>(params);
25+
26+
// Assess
27+
expect(result).toEqual(payload);
28+
});
29+
30+
it('throws SchemaValidationError when payload is invalid', () => {
31+
// Prepare
32+
const payload = { name: 'John', age: '30' };
33+
const schema = {
34+
type: 'object',
35+
properties: {
36+
name: { type: 'string' },
37+
age: { type: 'number' },
38+
},
39+
required: ['name', 'age'],
40+
additionalProperties: false,
41+
};
42+
43+
const params: ValidateParams = { payload, schema };
44+
45+
// Act & Assess
46+
expect(() => validate(params)).toThrow(SchemaValidationError);
47+
});
48+
49+
it('extracts data using envelope when provided', () => {
50+
// Prepare
51+
const payload = {
52+
data: {
53+
user: { name: 'Alice', age: 25 },
54+
},
55+
};
56+
const schema = {
57+
type: 'object',
58+
properties: {
59+
name: { type: 'string' },
60+
age: { type: 'number' },
61+
},
62+
required: ['name', 'age'],
63+
additionalProperties: false,
64+
};
65+
66+
const envelope = 'data.user';
67+
const params: ValidateParams = { payload, schema, envelope };
68+
69+
// Act
70+
const result = validate(params);
71+
72+
// Assess
73+
expect(result).toEqual({ name: 'Alice', age: 25 });
74+
});
75+
76+
it('uses provided ajv instance and custom formats', () => {
77+
// Prepare
78+
const payload = { email: '[email protected]' };
79+
const schema = {
80+
type: 'object',
81+
properties: {
82+
email: { type: 'string', format: 'custom-email' },
83+
},
84+
required: ['email'],
85+
additionalProperties: false,
86+
};
87+
88+
const ajvInstance = new Ajv({ allErrors: true });
89+
const formats = {
90+
'custom-email': {
91+
type: 'string',
92+
validate: (email: string) => email.includes('@'),
93+
},
94+
};
95+
96+
const params: ValidateParams = {
97+
payload,
98+
schema,
99+
ajv: ajvInstance,
100+
formats,
101+
};
102+
103+
// Act
104+
const result = validate(params);
105+
106+
// Assess
107+
expect(result).toEqual(payload);
108+
});
109+
110+
it('adds external schemas to ajv instance when provided', () => {
111+
// Prepare
112+
const externalSchema = {
113+
$id: 'http://example.com/schemas/address.json',
114+
type: 'object',
115+
properties: {
116+
street: { type: 'string' },
117+
city: { type: 'string' },
118+
},
119+
required: ['street', 'city'],
120+
additionalProperties: false,
121+
};
122+
123+
const schema = {
124+
type: 'object',
125+
properties: {
126+
address: { $ref: 'http://example.com/schemas/address.json' },
127+
},
128+
required: ['address'],
129+
additionalProperties: false,
130+
};
131+
132+
const payload = {
133+
address: {
134+
street: '123 Main St',
135+
city: 'Metropolis',
136+
},
137+
};
138+
139+
const params: ValidateParams = {
140+
payload,
141+
schema,
142+
externalRefs: [externalSchema],
143+
};
144+
145+
// Act
146+
const result = validate(params);
147+
148+
// Assess
149+
expect(result).toEqual(payload);
150+
});
151+
152+
it('throws SchemaValidationError when schema compilation fails', () => {
153+
// Prepare
154+
const payload = { name: 'John' };
155+
const schema = {
156+
type: 'object',
157+
properties: {
158+
name: { type: 'invalid-type' },
159+
},
160+
};
161+
162+
const params: ValidateParams = { payload, schema };
163+
164+
// Act & Assess
165+
expect(() => validate(params)).toThrow(SchemaValidationError);
166+
});
167+
});

0 commit comments

Comments
 (0)