Skip to content
Merged
62 changes: 62 additions & 0 deletions packages/validation/src/decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { SchemaValidationError } from './errors.js';
import type { ValidatorOptions } from './types.js';
import { validate } from './validate.js';
export function validator(options: ValidatorOptions) {
return (
_target: unknown,
_propertyKey: string | symbol,
descriptor: PropertyDescriptor
) => {
if (!descriptor.value) {
return descriptor;
}
const {
inboundSchema,
outboundSchema,
envelope,
formats,
externalRefs,
ajv,
} = options;
if (!inboundSchema && !outboundSchema) {
return descriptor;
}
const originalMethod = descriptor.value;
descriptor.value = async function (...args: unknown[]) {
let validatedInput = args[0];
if (inboundSchema) {
try {
validatedInput = validate({
payload: validatedInput,
schema: inboundSchema,
envelope: envelope,
formats: formats,
externalRefs: externalRefs,
ajv: ajv,
});
} catch (error) {
throw new SchemaValidationError('Inbound validation failed', error);
}
}
const result = await originalMethod.apply(this, [
validatedInput,
...args.slice(1),
]);
if (outboundSchema) {
try {
return validate({
payload: result,
schema: outboundSchema,
formats: formats,
externalRefs: externalRefs,
ajv: ajv,
});
} catch (error) {
throw new SchemaValidationError('Outbound Validation failed', error);
}
}
return result;
};
return descriptor;
};
}
3 changes: 2 additions & 1 deletion packages/validation/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { validate } from './validate';
export { validate } from './validate.js';
export { SchemaValidationError } from './errors.js';
export { validator } from './decorator.js';
36 changes: 27 additions & 9 deletions packages/validation/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
import type Ajv from 'ajv';
export interface ValidateParams<T = unknown> {
import type {
Ajv,
AnySchema,
AsyncFormatDefinition,
FormatDefinition,
} from 'ajv';

type Prettify<T> = {
[K in keyof T]: T[K];
} & {};

type ValidateParams = {
payload: unknown;
schema: object;
schema: AnySchema;
envelope?: string;
formats?: Record<
string,
| string
| RegExp
| {
type?: 'string' | 'number';
validate: (data: string) => boolean;
async?: boolean;
}
| FormatDefinition<string>
| FormatDefinition<number>
| AsyncFormatDefinition<string>
| AsyncFormatDefinition<number>
>;
externalRefs?: object[];
ajv?: Ajv;
}
};

type ValidatorOptions = Prettify<
Omit<ValidateParams, 'payload' | 'schema'> & {
inboundSchema?: AnySchema;
outboundSchema?: AnySchema;
}
>;

export type { ValidateParams, ValidatorOptions };
4 changes: 2 additions & 2 deletions packages/validation/src/validate.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { search } from '@aws-lambda-powertools/jmespath';
import Ajv, { type ValidateFunction } from 'ajv';
import { Ajv, type ValidateFunction } from 'ajv';
import { SchemaValidationError } from './errors.js';
import type { ValidateParams } from './types.js';

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

Expand Down
134 changes: 134 additions & 0 deletions packages/validation/tests/unit/decorator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest';
import { validator } from '../../src/decorator.js';
import { SchemaValidationError } from '../../src/errors.js';

const inboundSchema = {
type: 'object',
properties: {
value: { type: 'number' },
},
required: ['value'],
additionalProperties: false,
};

const outboundSchema = {
type: 'object',
properties: {
result: { type: 'number' },
},
required: ['result'],
additionalProperties: false,
};

describe('validator decorator', () => {
it('should validate inbound and outbound successfully', async () => {
// Prepare
class TestClass {
@validator({ inboundSchema, outboundSchema })
async multiply(input: { value: number }): Promise<{ result: number }> {
return { result: input.value * 2 };
}
}
const instance = new TestClass();
const input = { value: 5 };
// Act
const output = await instance.multiply(input);
// Assess
expect(output).toEqual({ result: 10 });
});

it('should throw error on inbound validation failure', async () => {
// Prepare
class TestClass {
@validator({ inboundSchema, outboundSchema })
async multiply(input: { value: number }): Promise<{ result: number }> {
return { result: input.value * 2 };
}
}
const instance = new TestClass();
const invalidInput = { value: 'not a number' } as unknown as {
value: number;
};
// Act & Assess
await expect(instance.multiply(invalidInput)).rejects.toThrow(
SchemaValidationError
);
});

it('should throw error on outbound validation failure', async () => {
// Prepare
class TestClassInvalid {
@validator({ inboundSchema, outboundSchema })
async multiply(input: { value: number }): Promise<{ result: number }> {
return { result: 'invalid' } as unknown as { result: number };
}
}
const instance = new TestClassInvalid();
const input = { value: 5 };
// Act & Assess
await expect(instance.multiply(input)).rejects.toThrow(
SchemaValidationError
);
});

it('should no-op when no schemas are provided', async () => {
// Prepare
class TestClassNoOp {
@validator({})
async echo(input: unknown): Promise<unknown> {
return input;
}
}
const instance = new TestClassNoOp();
const data = { foo: 'bar' };
// Act
const result = await instance.echo(data);
// Assess
expect(result).toEqual(data);
});

it('should return descriptor unmodified if descriptor.value is undefined', () => {
// Prepare
const descriptor: PropertyDescriptor = {};
// Act
const result = validator({ inboundSchema })(
null as unknown as object,
'testMethod',
descriptor
);
// Assess
expect(result).toEqual(descriptor);
});

it('should validate inbound only', async () => {
// Prepare
class TestClassInbound {
@validator({ inboundSchema })
async process(input: { value: number }): Promise<{ data: string }> {
return { data: JSON.stringify(input) };
}
}
const instance = new TestClassInbound();
const input = { value: 10 };
// Act
const output = await instance.process(input);
// Assess
expect(output).toEqual({ data: JSON.stringify(input) });
});

it('should validate outbound only', async () => {
// Prepare
class TestClassOutbound {
@validator({ outboundSchema })
async process(input: { text: string }): Promise<{ result: number }> {
return { result: 42 };
}
}
const instance = new TestClassOutbound();
const input = { text: 'hello' };
// Act
const output = await instance.process(input);
// Assess
expect(output).toEqual({ result: 42 });
});
});