Skip to content

Commit ae6b7cf

Browse files
feat(validation): add @validator decorator for JSON Schema validation (#3679)
Co-authored-by: Andrea Amorosi <[email protected]>
1 parent 471492d commit ae6b7cf

File tree

5 files changed

+227
-12
lines changed

5 files changed

+227
-12
lines changed

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

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { SchemaValidationError } from './errors.js';
2+
import type { ValidatorOptions } from './types.js';
3+
import { validate } from './validate.js';
4+
export function validator(options: ValidatorOptions) {
5+
return (
6+
_target: unknown,
7+
_propertyKey: string | symbol,
8+
descriptor: PropertyDescriptor
9+
) => {
10+
if (!descriptor.value) {
11+
return descriptor;
12+
}
13+
const {
14+
inboundSchema,
15+
outboundSchema,
16+
envelope,
17+
formats,
18+
externalRefs,
19+
ajv,
20+
} = options;
21+
if (!inboundSchema && !outboundSchema) {
22+
return descriptor;
23+
}
24+
const originalMethod = descriptor.value;
25+
descriptor.value = async function (...args: unknown[]) {
26+
let validatedInput = args[0];
27+
if (inboundSchema) {
28+
try {
29+
validatedInput = validate({
30+
payload: validatedInput,
31+
schema: inboundSchema,
32+
envelope: envelope,
33+
formats: formats,
34+
externalRefs: externalRefs,
35+
ajv: ajv,
36+
});
37+
} catch (error) {
38+
throw new SchemaValidationError('Inbound validation failed', error);
39+
}
40+
}
41+
const result = await originalMethod.apply(this, [
42+
validatedInput,
43+
...args.slice(1),
44+
]);
45+
if (outboundSchema) {
46+
try {
47+
return validate({
48+
payload: result,
49+
schema: outboundSchema,
50+
formats: formats,
51+
externalRefs: externalRefs,
52+
ajv: ajv,
53+
});
54+
} catch (error) {
55+
throw new SchemaValidationError('Outbound Validation failed', error);
56+
}
57+
}
58+
return result;
59+
};
60+
return descriptor;
61+
};
62+
}

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
export { validate } from './validate';
1+
export { validate } from './validate.js';
22
export { SchemaValidationError } from './errors.js';
3+
export { validator } from './decorator.js';

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

+27-9
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,36 @@
1-
import type Ajv from 'ajv';
2-
export interface ValidateParams<T = unknown> {
1+
import type {
2+
Ajv,
3+
AnySchema,
4+
AsyncFormatDefinition,
5+
FormatDefinition,
6+
} from 'ajv';
7+
8+
type Prettify<T> = {
9+
[K in keyof T]: T[K];
10+
} & {};
11+
12+
type ValidateParams = {
313
payload: unknown;
4-
schema: object;
14+
schema: AnySchema;
515
envelope?: string;
616
formats?: Record<
717
string,
818
| string
919
| RegExp
10-
| {
11-
type?: 'string' | 'number';
12-
validate: (data: string) => boolean;
13-
async?: boolean;
14-
}
20+
| FormatDefinition<string>
21+
| FormatDefinition<number>
22+
| AsyncFormatDefinition<string>
23+
| AsyncFormatDefinition<number>
1524
>;
1625
externalRefs?: object[];
1726
ajv?: Ajv;
18-
}
27+
};
28+
29+
type ValidatorOptions = Prettify<
30+
Omit<ValidateParams, 'payload' | 'schema'> & {
31+
inboundSchema?: AnySchema;
32+
outboundSchema?: AnySchema;
33+
}
34+
>;
35+
36+
export type { ValidateParams, ValidatorOptions };

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { search } from '@aws-lambda-powertools/jmespath';
2-
import Ajv, { type ValidateFunction } from 'ajv';
2+
import { Ajv, type ValidateFunction } from 'ajv';
33
import { SchemaValidationError } from './errors.js';
44
import type { ValidateParams } from './types.js';
55

6-
export function validate<T = unknown>(params: ValidateParams<T>): T {
6+
export function validate<T = unknown>(params: ValidateParams): T {
77
const { payload, schema, envelope, formats, externalRefs, ajv } = params;
88
const ajvInstance = ajv || new Ajv({ allErrors: true });
99

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

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { validator } from '../../src/decorator.js';
3+
import { SchemaValidationError } from '../../src/errors.js';
4+
5+
const inboundSchema = {
6+
type: 'object',
7+
properties: {
8+
value: { type: 'number' },
9+
},
10+
required: ['value'],
11+
additionalProperties: false,
12+
};
13+
14+
const outboundSchema = {
15+
type: 'object',
16+
properties: {
17+
result: { type: 'number' },
18+
},
19+
required: ['result'],
20+
additionalProperties: false,
21+
};
22+
23+
describe('validator decorator', () => {
24+
it('should validate inbound and outbound successfully', async () => {
25+
// Prepare
26+
class TestClass {
27+
@validator({ inboundSchema, outboundSchema })
28+
async multiply(input: { value: number }): Promise<{ result: number }> {
29+
return { result: input.value * 2 };
30+
}
31+
}
32+
const instance = new TestClass();
33+
const input = { value: 5 };
34+
// Act
35+
const output = await instance.multiply(input);
36+
// Assess
37+
expect(output).toEqual({ result: 10 });
38+
});
39+
40+
it('should throw error on inbound validation failure', async () => {
41+
// Prepare
42+
class TestClass {
43+
@validator({ inboundSchema, outboundSchema })
44+
async multiply(input: { value: number }): Promise<{ result: number }> {
45+
return { result: input.value * 2 };
46+
}
47+
}
48+
const instance = new TestClass();
49+
const invalidInput = { value: 'not a number' } as unknown as {
50+
value: number;
51+
};
52+
// Act & Assess
53+
await expect(instance.multiply(invalidInput)).rejects.toThrow(
54+
SchemaValidationError
55+
);
56+
});
57+
58+
it('should throw error on outbound validation failure', async () => {
59+
// Prepare
60+
class TestClassInvalid {
61+
@validator({ inboundSchema, outboundSchema })
62+
async multiply(input: { value: number }): Promise<{ result: number }> {
63+
return { result: 'invalid' } as unknown as { result: number };
64+
}
65+
}
66+
const instance = new TestClassInvalid();
67+
const input = { value: 5 };
68+
// Act & Assess
69+
await expect(instance.multiply(input)).rejects.toThrow(
70+
SchemaValidationError
71+
);
72+
});
73+
74+
it('should no-op when no schemas are provided', async () => {
75+
// Prepare
76+
class TestClassNoOp {
77+
@validator({})
78+
async echo(input: unknown): Promise<unknown> {
79+
return input;
80+
}
81+
}
82+
const instance = new TestClassNoOp();
83+
const data = { foo: 'bar' };
84+
// Act
85+
const result = await instance.echo(data);
86+
// Assess
87+
expect(result).toEqual(data);
88+
});
89+
90+
it('should return descriptor unmodified if descriptor.value is undefined', () => {
91+
// Prepare
92+
const descriptor: PropertyDescriptor = {};
93+
// Act
94+
const result = validator({ inboundSchema })(
95+
null as unknown as object,
96+
'testMethod',
97+
descriptor
98+
);
99+
// Assess
100+
expect(result).toEqual(descriptor);
101+
});
102+
103+
it('should validate inbound only', async () => {
104+
// Prepare
105+
class TestClassInbound {
106+
@validator({ inboundSchema })
107+
async process(input: { value: number }): Promise<{ data: string }> {
108+
return { data: JSON.stringify(input) };
109+
}
110+
}
111+
const instance = new TestClassInbound();
112+
const input = { value: 10 };
113+
// Act
114+
const output = await instance.process(input);
115+
// Assess
116+
expect(output).toEqual({ data: JSON.stringify(input) });
117+
});
118+
119+
it('should validate outbound only', async () => {
120+
// Prepare
121+
class TestClassOutbound {
122+
@validator({ outboundSchema })
123+
async process(input: { text: string }): Promise<{ result: number }> {
124+
return { result: 42 };
125+
}
126+
}
127+
const instance = new TestClassOutbound();
128+
const input = { text: 'hello' };
129+
// Act
130+
const output = await instance.process(input);
131+
// Assess
132+
expect(output).toEqual({ result: 42 });
133+
});
134+
});

0 commit comments

Comments
 (0)