Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(framework): add class-validator support #6840

Open
wants to merge 3 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
26 changes: 24 additions & 2 deletions packages/framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"next",
"nuxt",
"remix",
"sveltekit",
"sveltekit",
"README.md"
],
"scripts": {
Expand Down Expand Up @@ -170,9 +170,13 @@
"@sveltejs/kit": ">=1.27.3",
"@vercel/node": ">=2.15.9",
"aws-lambda": ">=1.0.7",
"class-transformer": ">=0.5.1",
"class-validator": ">=0.14.0",
"class-validator-jsonschema": ">=5.0.0",
"express": ">=4.19.2",
"h3": ">=1.8.1",
"next": ">=12.0.0",
"reflect-metadata": ">=0.2.2",
"zod": ">=3.0.0",
"zod-to-json-schema": ">=3.0.0"
},
Expand Down Expand Up @@ -201,6 +205,18 @@
"next": {
"optional": true
},
"class-transformer": {
"optional": true
},
"class-validator": {
"optional": true
},
"class-validator-jsonschema": {
"optional": true
},
"reflect-metadata": {
"optional": true
},
"zod": {
"optional": true
},
Expand All @@ -218,11 +234,15 @@
"@types/sanitize-html": "2.11.0",
"@vercel/node": "^2.15.9",
"aws-lambda": "^1.0.7",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"class-validator-jsonschema": "^5.0.1",
"express": "^4.19.2",
"h3": "^1.11.1",
"madge": "^8.0.0",
"next": "^13.5.4",
"prettier": "^3.2.5",
"reflect-metadata": "^0.2.2",
"ts-node": "^10.9.2",
"tsup": "^8.0.2",
"tsx": "4.16.2",
Expand All @@ -243,6 +263,8 @@
"sanitize-html": "^2.13.0"
},
"nx": {
"tags": ["package:public"]
"tags": [
"package:public"
]
}
}
179 changes: 179 additions & 0 deletions packages/framework/src/client.validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable no-param-reassign */
import { expect, it, describe, beforeEach } from 'vitest';
import { z } from 'zod';
import { IsNumber, IsOptional, IsString } from 'class-validator';
import { Client } from './client';
import { workflow } from './resources/workflow';
import { ExecutionStateControlsInvalidError } from './errors';
Expand Down Expand Up @@ -187,6 +188,184 @@ describe('validation', () => {
});
});

describe('class-validator', () => {
class ClassValidatorSchema {
@IsString()
@IsOptional()
foo?: string;
@IsNumber()
@IsOptional()
baz?: number;
}

it('should infer types in the step controls', async () => {
workflow('class-validator-validation', async ({ step }) => {
await step.email(
'class-validator-validation',
async (controls) => {
// @ts-expect-error - Type 'number' is not assignable to type 'string'.
controls.foo = 123;
// @ts-expect-error - Type 'string' is not assignable to type 'number'.
controls.baz = '123';

return {
subject: 'Test subject',
body: 'Test body',
};
},
{
controlSchema: ClassValidatorSchema,
skip: (controls) => {
// @ts-expect-error - Type 'number' is not assignable to type 'string'.
controls.foo = 123;
// @ts-expect-error - Type 'string' is not assignable to type 'number'.
controls.baz = '123';

return true;
},
providers: {
sendgrid: async ({ controls, outputs }) => {
// @ts-expect-error - Type 'number' is not assignable to type 'string'.
controls.foo = 123;
// @ts-expect-error - Type 'string' is not assignable to type 'number'.
controls.baz = '123';

// @ts-expect-error - Type 'number' is not assignable to type 'string'.
outputs.body = 123;
// @ts-expect-error - Type 'number' is not assignable to type 'string'.
outputs.subject = 123;

return {
ipPoolName: 'test',
};
},
},
}
);
});
});

it('should infer types in the workflow payload', async () => {
workflow(
'class-validator-validation',
async ({ step, payload }) => {
await step.email('class-validator-validation', async () => {
// @ts-expect-error - Type 'number' is not assignable to type 'string'.
payload.foo = 123;
// @ts-expect-error - Type 'string' is not assignable to type 'number'.
payload.baz = '123';

return {
subject: 'Test subject',
body: 'Test body',
};
});
},
{
payloadSchema: ClassValidatorSchema,
}
);
});

it('should infer types in the workflow controls', async () => {
workflow(
'class-validator-validation',
async ({ step, controls }) => {
await step.email('class-validator-validation', async () => {
// @ts-expect-error - Type 'number' is not assignable to type 'string'.
controls.foo = 123;
// @ts-expect-error - Type 'string' is not assignable to type 'number'.
controls.baz = '123';

return {
subject: 'Test subject',
body: 'Test body',
};
});
},
{
controlSchema: ClassValidatorSchema,
}
);
});

it('should transform a class-validator schema to a json schema during discovery', async () => {
client.addWorkflows([
workflow('class-validator-validation', async ({ step }) => {
await step.email(
'class-validator-validation',
async () => ({
subject: 'Test subject',
body: 'Test body',
}),
{
controlSchema: ClassValidatorSchema,
}
);
}),
]);

const discoverResult = client.discover();
const stepControlSchema = discoverResult.workflows[0].steps[0].controls.schema;

expect(stepControlSchema).to.deep.include({
additionalProperties: false,
properties: {
foo: {
type: 'string',
},
baz: {
type: 'number',
},
},
required: ['foo', 'baz'],
type: 'object',
});
});

it('should throw an error if a property is missing', async () => {
client.addWorkflows([
workflow('class-validator-validation', async ({ step }) => {
await step.email(
'test-email',
async () => ({
subject: 'Test subject',
body: 'Test body',
}),
{
controlSchema: ClassValidatorSchema,
}
);
}),
]);

try {
await client.executeWorkflow({
action: PostActionEnum.EXECUTE,
workflowId: 'class-validator-validation',
controls: {
foo: '341',
},
payload: {},
stepId: 'test-email',
state: [],
subscriber: {},
});
} catch (error) {
expect(error).to.be.instanceOf(ExecutionStateControlsInvalidError);
expect((error as ExecutionStateControlsInvalidError).message).to.equal(
'Workflow with id: `class-validator-validation` has an invalid state. Step with id: `test-email` has invalid `controls`. Please provide the correct step controls.'
);
expect((error as ExecutionStateControlsInvalidError).data).to.deep.equal([
{
message: 'Required',
path: '/baz',
},
]);
}
});
});

describe('json-schema', () => {
const jsonSchema = {
type: 'object',
Expand Down
19 changes: 14 additions & 5 deletions packages/framework/src/types/schema.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import zod from 'zod';

export type JsonSchema = JSONSchema;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ClassType<T = any> = new (...args: any[]) => T;

/**
* A schema used to validate a JSON object.
*
* Supported schemas:
* - JSONSchema
* - ZodSchema
*/
export type Schema = JsonSchema | zod.ZodSchema;
export type Schema = JsonSchema | zod.ZodSchema | ClassType;

/**
* Infer the type of a Schema for unvalidated data.
Expand All @@ -36,8 +39,11 @@ export type FromSchemaUnvalidated<T extends Schema> =
: // ZodSchema
T extends zod.ZodSchema
? zod.input<T>
: // All schema types exhausted.
never;
: // ClassValidatorSchema
T extends ClassType<infer U>
? U
: // All schema types exhausted.
never;

/**
* Infer the type of a Schema for validated data.
Expand All @@ -63,5 +69,8 @@ export type FromSchema<T extends Schema> =
: // ZodSchema
T extends zod.ZodSchema
? zod.infer<T>
: // All schema types exhausted.
never;
: // ClassValidatorSchema
T extends ClassType<infer U>
? U
: // All schema types exhausted.
never;
9 changes: 6 additions & 3 deletions packages/framework/src/types/step.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ export type ActionStep<
/**
* The controls for the step.
*/
T_Controls extends Record<string, unknown> = FromSchema<T_ControlSchema>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T_Controls extends Record<string, any> = FromSchema<T_ControlSchema>,
>(
/**
* The name of the step. This is used to identify the step in the workflow.
Expand Down Expand Up @@ -94,7 +95,8 @@ export type CustomStep = <
/**
* The controls for the step.
*/
T_Controls extends Record<string, unknown> = FromSchema<T_ControlSchema>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T_Controls extends Record<string, any> = FromSchema<T_ControlSchema>,
/*
* These intermediary types are needed to capture the types in a single type instance
* to stop Typescript from erroring with:
Expand Down Expand Up @@ -153,7 +155,8 @@ export type ChannelStep<
/**
* The controls for the step.
*/
T_Controls extends Record<string, unknown> = FromSchema<T_ControlSchema>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T_Controls extends Record<string, any> = FromSchema<T_ControlSchema>,
>(
/**
* The name of the step. This is used to identify the step in the workflow.
Expand Down
6 changes: 6 additions & 0 deletions packages/framework/src/validators/base.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import type { FromSchema, FromSchemaUnvalidated, JsonSchema, Schema } from '../t
import type { ValidateResult } from '../types/validator.types';
import { JsonSchemaValidator } from './json-schema.validator';
import { ZodValidator } from './zod.validator';
import { ClassValidatorValidator } from './class-validator.validator';

const zodValidator = new ZodValidator();
const classValidatorValidator = new ClassValidatorValidator();
const jsonSchemaValidator = new JsonSchemaValidator();

export const validateData = async <
Expand All @@ -16,6 +18,8 @@ export const validateData = async <
): Promise<ValidateResult<T_Validated>> => {
if (zodValidator.canHandle(schema)) {
return zodValidator.validate(data, schema);
} else if (classValidatorValidator.canHandle(schema)) {
return classValidatorValidator.validate(data, schema);
} else if (jsonSchemaValidator.canHandle(schema)) {
return jsonSchemaValidator.validate(data, schema);
}
Expand All @@ -26,6 +30,8 @@ export const validateData = async <
export const transformSchema = (schema: Schema): JsonSchema => {
if (zodValidator.canHandle(schema)) {
return zodValidator.transformToJsonSchema(schema);
} else if (classValidatorValidator.canHandle(schema)) {
return classValidatorValidator.transformToJsonSchema(schema);
} else if (jsonSchemaValidator.canHandle(schema)) {
return jsonSchemaValidator.transformToJsonSchema(schema);
}
Expand Down
Loading
Loading