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

Closed
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
369ffaf
feat: add class-validator support
paulwer Nov 4, 2024
19c03c2
Merge branch 'next' into feat-package-class-validator-support
paulwer Nov 4, 2024
a16f147
Merge branch 'next' into feat-package-class-validator-support
paulwer Nov 5, 2024
1016672
Merge branch 'next' into feat-package-class-validator-support
rifont Nov 8, 2024
c2c0512
Update packages/framework/src/validators/class-validator.validator.ts
paulwer Nov 8, 2024
f343a9d
fixes
paulwer Nov 8, 2024
b91fe48
Merge branch 'next' into feat-package-class-validator-support
paulwer Nov 8, 2024
8662b2b
workaround for classValidator defaultStorage
paulwer Nov 8, 2024
df9128e
Update packages/framework/src/validators/class-validator.validator.ts
paulwer Nov 8, 2024
86bd4f9
fixes
paulwer Nov 8, 2024
5d2b8e9
Merge remote-tracking branch 'refs/remotes/origin/feat-package-class-…
paulwer Nov 8, 2024
2b8f000
Merge branch 'next' into feat-package-class-validator-support
paulwer Nov 8, 2024
f74470a
refactor(types): replace any with unknown in T_Controls
rifont Nov 8, 2024
e7f3ef8
Update packages/framework/src/validators/class-validator.validator.ts
rifont Nov 8, 2024
c5daec5
refactor(validator): simplify metadata storage import
rifont Nov 8, 2024
255115c
docs(validator): update TODO comment with @see link
rifont Nov 8, 2024
68e5c9c
test(validation): update tests for validation changes
rifont Nov 8, 2024
ecec05d
refactor(types): update class type inference logic
rifont Nov 8, 2024
836bde3
test: remove JSON stringify from test assertion
rifont Nov 8, 2024
f3decf0
docs(schema.types): update ClassType type description
rifont Nov 8, 2024
ed7c857
refactor(class-validator): improve type checking logic
rifont Nov 8, 2024
322d118
test(validator): extract class-validator fixtures
rifont Nov 8, 2024
7fb6368
Merge branch 'next' into feat-package-class-validator-support
rifont Nov 8, 2024
b432668
refactor: Remove IsOptional from name property
rifont Nov 8, 2024
11210c4
Merge branch 'feat-package-class-validator-support' of https://github…
rifont Nov 8, 2024
ff1577e
fix(framework): Add dependency check import utilities
rifont Nov 10, 2024
34f9add
Merge branch 'import-utils-refactor-schemas' into feat-package-class-…
rifont Nov 11, 2024
d5c304b
Update packages/framework/src/types/schema.types/zod.schema.types.ts
rifont Nov 11, 2024
1281a14
Update packages/framework/src/client.test.ts
rifont Nov 11, 2024
d29556d
Update packages/framework/package.json
rifont Nov 11, 2024
0ab32ec
Update packages/framework/src/validators/class-validator.validator.ts
rifont Nov 11, 2024
dbf050e
Merge branch 'import-utils-refactor-schemas' into feat-package-class-…
rifont Nov 11, 2024
37bc8bc
Update packages/framework/src/validators/validator.test.ts
rifont Nov 11, 2024
b918978
Merge branch 'import-utils-refactor-schemas' into feat-package-class-…
rifont Nov 11, 2024
4140bda
Merge branch 'import-utils-refactor-schemas' into feat-package-class-…
rifont Nov 11, 2024
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
20 changes: 20 additions & 0 deletions packages/framework/package.json
Original file line number Diff line number Diff line change
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 @@ -219,11 +235,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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ describe('FromSchema', () => {

expectTypeOf<FromSchema<typeof testZodSchema>>().toEqualTypeOf<{ foo: string; bar?: string }>();
});

it('should infer a Class Schema type', () => {
class TestSchema {
foo: string = 'bar';
bar?: string;
}

expectTypeOf<FromSchema<typeof TestSchema>>().toEqualTypeOf<{ foo: string; bar?: string }>();
});
});

describe('FromSchemaUnvalidated', () => {
Expand Down Expand Up @@ -56,4 +65,13 @@ describe('FromSchemaUnvalidated', () => {

expectTypeOf<FromSchemaUnvalidated<typeof testZodSchema>>().toEqualTypeOf<{ foo?: string; bar?: string }>();
});

it('should infer a Class Schema type', () => {
class TestClassSchema {
foo?: string = 'bar';
bar?: string;
}

expectTypeOf<FromSchemaUnvalidated<typeof TestClassSchema>>().toEqualTypeOf<{ foo?: string; bar?: string }>();
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { ClassValidatorSchema, InferClassValidatorSchema } from './class.schema.types';
import type { JsonSchema, InferJsonSchema } from './json.schema.types';
import type { ZodSchemaMinimal, InferZodSchema } from './zod.schema.types';

/**
* A schema used to validate a JSON object.
*/
export type Schema = JsonSchema | ZodSchemaMinimal;
export type Schema = JsonSchema | ZodSchemaMinimal | ClassValidatorSchema;

/**
* Main utility type for schema inference
Expand All @@ -15,6 +16,7 @@ export type Schema = JsonSchema | ZodSchemaMinimal;
type InferSchema<T extends Schema, Options extends { validated: boolean }> =
| InferJsonSchema<T, Options>
| InferZodSchema<T, Options>
| InferClassValidatorSchema<T, Options>
| never extends infer U
? // If all inferred types are `never`, return an unknown record
[U] extends [never]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expectTypeOf, it } from 'vitest';
import { InferClassValidatorSchema } from './class.schema.types';

describe('ClassSchema types', () => {
class TestSchema {
foo: string = 'bar';
bar?: string;
}

describe('validated data', () => {
it('should compile when the expected properties are provided', () => {
expectTypeOf<InferClassValidatorSchema<typeof TestSchema, { validated: true }>>().toEqualTypeOf<{
foo: string;
bar?: string;
}>();
});

it('should not compile when the schema is not a ClassSchema', () => {
expectTypeOf<InferClassValidatorSchema<string, { validated: true }>>().toEqualTypeOf<never>();
});

it('should not compile when a property does not match the expected type', () => {
// @ts-expect-error - Type 'number' is not assignable to type 'string'.
expectTypeOf<InferClassValidatorSchema<typeof TestSchema, { validated: true }>>().toEqualTypeOf<{
foo: number;
}>();
});
});

describe('unvalidated data', () => {
/**
* TODO: Support accessing defaulted properties when Typescript supports it.
*/
it.skip('should keep the defaulted properties optional', () => {
// @ts-expect-error - Type 'undefined' is not assignable to type 'string'.
expectTypeOf<InferClassValidatorSchema<typeof TestSchema, { validated: false }>>().toEqualTypeOf<{
foo?: string;
bar?: string;
}>();
});
});
});
44 changes: 44 additions & 0 deletions packages/framework/src/types/schema.types/class.schema.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Prettify } from '../util.types';

/**
* A type that represents a class.
*/
export type ClassValidatorSchema<T = unknown> = new (...args: unknown[]) => T;

/**
* Extract the properties of a class type.
*/
export type ClassPropsInfer<T extends ClassValidatorSchema> =
T extends ClassValidatorSchema<infer R> ? Prettify<R> : never;

/**
* Infer the data type of a ClassValidatorSchema.
*
* @param T - The ClassValidatorSchema to infer the data type of.
* @param Options - Configuration options for the type inference. The `validated` flag determines whether the schema has been validated. If `validated` is true, all properties are required unless specified otherwise. If false, properties with default values are optional.
*
* @returns The inferred type.
*
* @example
* ```ts
* class MySchema {
* @IsString()
* @IsNotEmpty()
* name: string;
*
* @IsEmail()
* @IsOptional()
* email?: string;
* }
*
* // has type { name: string, email?: string }
* type MySchema = InferClassValidatorSchema<typeof MySchema, { validated: true }>;
* ```
*/
export type InferClassValidatorSchema<T, Options extends { validated: boolean }> = T extends ClassValidatorSchema
? Options['validated'] extends true
? ClassPropsInfer<T>
: // ClassSchema doesn't support default properties, so the resulting type
// will not have default properties set to optional.
ClassPropsInfer<T>
: never;
1 change: 1 addition & 0 deletions packages/framework/src/types/schema.types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export type { JsonSchema } from './json.schema.types';
export type { ZodSchemaMinimal, ZodSchema } from './zod.schema.types';
export type { ClassValidatorSchema } from './class.schema.types';
export type { Schema, FromSchema, FromSchemaUnvalidated } from './base.schema.types';
15 changes: 14 additions & 1 deletion packages/framework/src/validators/base.validator.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import type { FromSchema, FromSchemaUnvalidated, Schema, JsonSchema, ZodSchema } from '../types/schema.types';
import type {
FromSchema,
FromSchemaUnvalidated,
ClassValidatorSchema,
Schema,
JsonSchema,
ZodSchema,
} from '../types/schema.types';
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();

/**
Expand All @@ -28,6 +37,8 @@ export const validateData = async <
*/
if (await zodValidator.canHandle(schema)) {
return zodValidator.validate(data, schema as ZodSchema);
} else if (await classValidatorValidator.canHandle(schema)) {
return classValidatorValidator.validate(data, schema as ClassValidatorSchema);
} else if (await jsonSchemaValidator.canHandle(schema)) {
return jsonSchemaValidator.validate(data, schema as JsonSchema);
}
Expand All @@ -44,6 +55,8 @@ export const validateData = async <
export const transformSchema = async (schema: Schema): Promise<JsonSchema> => {
if (await zodValidator.canHandle(schema)) {
return zodValidator.transformToJsonSchema(schema as ZodSchema);
} else if (await classValidatorValidator.canHandle(schema)) {
return classValidatorValidator.transformToJsonSchema(schema as ClassValidatorSchema);
} else if (await jsonSchemaValidator.canHandle(schema)) {
return jsonSchemaValidator.transformToJsonSchema(schema as JsonSchema);
}
Expand Down
163 changes: 163 additions & 0 deletions packages/framework/src/validators/class-validator.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { ValidationError } from 'class-validator';
import type {
FromSchema,
FromSchemaUnvalidated,
Schema,
JsonSchema,
ClassValidatorSchema,
} from '../types/schema.types';
import type { ValidateResult, Validator } from '../types/validator.types';
import { checkDependencies } from '../utils/import.utils';
import { ImportRequirement } from '../types/import.types';

// Function to recursively add `additionalProperties: false` to the schema
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function replaceSchemaRefs(schema: any, schemas: any): JsonSchema {
if (schema && typeof schema === 'object' && schema?.$ref) {
// eslint-disable-next-line no-param-reassign
schema = schemas[schema.$ref.split('/').at(-1)];
}

if (schema && typeof schema === 'object')
for (const key in schema) {
if (schema.hasOwnProperty(key)) {
// eslint-disable-next-line no-param-reassign
schema[key] = replaceSchemaRefs(schema[key], schemas);
}
}

return schema;
}
// Function to recursively add `additionalProperties: false` to the schema
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function addAdditionalPropertiesFalse(schema: any): JsonSchema {
if (schema && typeof schema === 'object' && schema?.type === 'object') {
// eslint-disable-next-line no-param-reassign
schema.additionalProperties = false;
}

if (schema.properties) {
for (const key in schema.properties) {
if (schema.properties.hasOwnProperty(key)) {
addAdditionalPropertiesFalse(schema.properties[key]);
}
}
}

return schema;
}
function formatErrors(errors: ValidationError[], parentPath = ''): { path: string; message: string }[] {
return errors.flatMap((err) => {
const currentPath = `${parentPath}/${err.property}`.replace(/\/+/g, '/');

if (err.children && err.children.length > 0) {
// Recursively format the children
return formatErrors(err.children, currentPath);
} else {
// Base case: no children, return the formatted error
return {
path: currentPath,
message: Object.values(err.constraints || {}).join(', '),
};
}
});
}

export class ClassValidatorValidator implements Validator<ClassValidatorSchema> {
readonly requiredImports: readonly ImportRequirement[] = [
{
name: 'class-validator',
import: import('class-validator'),
exports: ['validate', 'getMetadataStorage'],
},
{
name: 'class-transformer',
import: import('class-transformer'),
exports: ['plainToInstance', 'instanceToPlain'],
},
{
name: 'class-transformer',
// @ts-expect-error - class-transformer doesn't export `defaultMetadataStorage` from the root module
import: import('class-transformer/cjs/storage'),
exports: ['defaultMetadataStorage'],
},
{
name: 'reflect-metadata',
import: import('reflect-metadata'),
exports: [],
},
{
name: 'class-validator-jsonschema',
import: import('class-validator-jsonschema'),
exports: ['validationMetadatasToSchemas', 'targetConstructorToSchema'],
},
];
paulwer marked this conversation as resolved.
Show resolved Hide resolved

async canHandle(schema: Schema): Promise<boolean> {
const canHandle =
typeof (schema as ClassValidatorSchema) === 'function' &&
(schema as ClassValidatorSchema).prototype !== undefined &&
(schema as ClassValidatorSchema).prototype.constructor === schema;

if (canHandle) {
await checkDependencies(this.requiredImports, 'Class Validator schema');
}

return canHandle;
}

async validate<
T_Schema extends ClassValidatorSchema = ClassValidatorSchema,
T_Unvalidated = FromSchemaUnvalidated<T_Schema>,
T_Validated = FromSchema<T_Schema>,
>(data: T_Unvalidated, schema: T_Schema): Promise<ValidateResult<T_Validated>> {
const { plainToInstance, instanceToPlain } = await import('class-transformer');
const { validate } = await import('class-validator');

// Convert plain data to an instance of the schema class
const instance = plainToInstance(schema, data);

// Validate the instance
const errors = await validate(instance as object, { whitelist: true });

// if undefined, then something went wrong
if (!instance && !!data) throw new Error('Failed to convert data to an instance of the schema class');

if (errors.length === 0) {
return { success: true, data: instanceToPlain(instance) as T_Validated };
} else {
return {
success: false,
errors: formatErrors(errors),
};
}
}

async transformToJsonSchema(schema: ClassValidatorSchema): Promise<JsonSchema> {
/*
* TODO: replace with direct import, when defaultMetadataStorage is exported by default
* @see https://github.com/typestack/class-transformer/issues/563#issuecomment-803262394
*/
// @ts-expect-error - class-transformer doesn't export `defaultMetadataStorage` from the root module
const { defaultMetadataStorage } = await import('class-transformer/cjs/storage');
const { getMetadataStorage } = await import('class-validator');
const { validationMetadatasToSchemas, targetConstructorToSchema } = await import('class-validator-jsonschema');

const schemas = validationMetadatasToSchemas({
classValidatorMetadataStorage: getMetadataStorage(),
classTransformerMetadataStorage: defaultMetadataStorage,
});

const transformedSchema = addAdditionalPropertiesFalse(
replaceSchemaRefs(
targetConstructorToSchema(schema, {
classValidatorMetadataStorage: getMetadataStorage(),
classTransformerMetadataStorage: defaultMetadataStorage,
}),
schemas
)
);

return transformedSchema;
}
}
Loading
Loading