Skip to content
Merged
79 changes: 79 additions & 0 deletions packages/validation/src/decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Ajv } from 'ajv';
import { SchemaValidationError } from './errors.js';
import { validate } from './validate.js';
export interface ValidatorOptions {
inboundSchema?: object;
outboundSchema?: object;
envelope?: string;
formats?: Record<
string,
| string
| RegExp
| {
type?: 'string' | 'number';
validate: (data: string) => boolean;
async?: boolean;
}
>;
externalRefs?: object[];
ajv?: Ajv;
}

type AsyncMethod = (...args: unknown[]) => Promise<unknown>;

export function validator(options: ValidatorOptions): MethodDecorator {
return (
_target,
_propertyKey,
descriptor: TypedPropertyDescriptor<AsyncMethod>
) => {
if (!descriptor.value) {
return descriptor;
}

if (!options.inboundSchema && !options.outboundSchema) {
return descriptor;
}

const originalMethod = descriptor.value;

descriptor.value = async function (...args: unknown[]): Promise<unknown> {
let validatedInput = args[0];

if (options.inboundSchema) {
try {
validatedInput = validate({
payload: args[0],
schema: options.inboundSchema,
envelope: options.envelope,
formats: options.formats,
externalRefs: options.externalRefs,
ajv: options.ajv,
});
} catch (error) {
throw new SchemaValidationError('Inbound validation failed', error);
}
}

const result = await originalMethod.apply(this, [
validatedInput,
...args.slice(1),
]);
if (options.outboundSchema) {
try {
return validate({
payload: result,
schema: options.outboundSchema,
formats: options.formats,
externalRefs: options.externalRefs,
ajv: options.ajv,
});
} catch (error) {
throw new SchemaValidationError('Outbound Validation failed', error);
}
}
return result;
};
return descriptor;
};
}
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,
'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 });
});
});
Loading