diff --git a/.cspell.json b/.cspell.json index f27ca27e322..675c8f8981a 100644 --- a/.cspell.json +++ b/.cspell.json @@ -710,6 +710,7 @@ "truncatewords", "xmlschema", "jsonify", + "metadatas", "touchpoint", "Angularjs", "navigatable" diff --git a/packages/framework/package.json b/packages/framework/package.json index 75a65225a39..c9de25d2303 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -38,7 +38,7 @@ "build:watch": "tsup --watch", "postbuild": "pnpm run check:exports && pnpm check:circulars", "check:exports": "attw --pack .", - "check:circulars": "madge --circular --extensions ts ./src", + "check:circulars": "madge --circular --extensions ts --exclude ../../shared ./src", "$comment:bump:prerelease": "This is a workaround to support `npm version prerelease` with lerna", "bump:prerelease": "npm version prerelease --preid=alpha & PID=$!; (sleep 1 && kill -9 $PID) & wait $PID", "release:alpha": "pnpm bump:prerelease || pnpm build && npm publish", @@ -169,9 +169,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" }, @@ -200,6 +204,18 @@ "next": { "optional": true }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "class-validator-jsonschema": { + "optional": true + }, + "reflect-metadata": { + "optional": true + }, "zod": { "optional": true }, @@ -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", diff --git a/packages/framework/src/resources/workflow/workflow.resource.test-d.ts b/packages/framework/src/resources/workflow/workflow.resource.test-d.ts index 3ad8e6ed0ee..3d792217f9b 100644 --- a/packages/framework/src/resources/workflow/workflow.resource.test-d.ts +++ b/packages/framework/src/resources/workflow/workflow.resource.test-d.ts @@ -109,13 +109,15 @@ describe('workflow function types', () => { additionalProperties: false, } as const; - it('should infer an unknown record type when the provided schema is for a primitive type', () => { + it('should infer an error message type when the provided schema is for a primitive type', () => { const primitiveSchema = { type: 'string' } as const; workflow('without-schema', async ({ step }) => { await step.email( 'without-schema', async (controls) => { - expectTypeOf(controls).toEqualTypeOf>(); + expectTypeOf(controls).toEqualTypeOf<{ + SchemaError: "Schema must describe an object data structure. Received data type: 'string'"; + }>(); return { subject: 'Test subject', @@ -123,13 +125,53 @@ describe('workflow function types', () => { }; }, { - // @ts-expect-error - schema is for a primitive type controlSchema: primitiveSchema, } ); }); }); + it('should infer an error message type when the provided schema is for an array type', () => { + const arraySchema = { type: 'array', items: { type: 'string' } } as const; + workflow('without-schema', async ({ step }) => { + await step.email( + 'without-schema', + async (controls) => { + expectTypeOf(controls).toEqualTypeOf<{ + SchemaError: `Schema must describe an object data structure. Received data type: 'string[]'`; + }>(); + + return { + subject: 'Test subject', + body: 'Test body', + }; + }, + { + controlSchema: arraySchema, + } + ); + }); + }); + + it('should infer an unknown record type when the provided schema is undefined', () => { + workflow('without-schema', async ({ step }) => { + await step.email( + 'without-schema', + async (controls) => { + expectTypeOf(controls).toEqualTypeOf>(); + + return { + subject: 'Test subject', + body: 'Test body', + }; + }, + { + controlSchema: undefined, + } + ); + }); + }); + it('should infer correct types in the step controls', async () => { workflow('json-schema', async ({ step }) => { await step.email( diff --git a/packages/framework/src/resources/workflow/workflow.resource.ts b/packages/framework/src/resources/workflow/workflow.resource.ts index 09bdc9e9453..62d9c5b3c78 100644 --- a/packages/framework/src/resources/workflow/workflow.resource.ts +++ b/packages/framework/src/resources/workflow/workflow.resource.ts @@ -45,7 +45,8 @@ export function workflow< if (validationResult.success === false) { throw new WorkflowPayloadInvalidError(workflowId, validationResult.errors); } - validatedData = validationResult.data as T_PayloadValidated; + // Coerce the validated data to handle unknown matching to SchemaError + validatedData = validationResult.data as Record as T_PayloadValidated; } else { // This type coercion provides support to trigger Workflows without a payload schema validatedData = event.payload as unknown as T_PayloadValidated; diff --git a/packages/framework/src/types/schema.types/base.schema.types.test-d.ts b/packages/framework/src/types/schema.types/base.schema.types.test-d.ts index 9110c2d47d4..24adb460740 100644 --- a/packages/framework/src/types/schema.types/base.schema.types.test-d.ts +++ b/packages/framework/src/types/schema.types/base.schema.types.test-d.ts @@ -4,16 +4,65 @@ import { FromSchema, FromSchemaUnvalidated, Schema } from './base.schema.types'; describe('FromSchema', () => { it('should infer an unknown record type when a generic schema is provided', () => { - expectTypeOf>().toEqualTypeOf>(); + type Test = FromSchema; + + expectTypeOf().toEqualTypeOf>(); }); - it('should not compile when the schema is primitive', () => { + it('should not compile and infer an unknown record type when the schema is undefined', () => { + // @ts-expect-error - Type 'undefined' does not satisfy the constraint 'Schema'. + type Test = FromSchema; + + expectTypeOf().toEqualTypeOf>(); + }); + + it('not compile when the schema is undefined', () => { + // @ts-expect-error - Type 'undefined' does not satisfy the constraint 'Schema'. + type Test = FromSchemaUnvalidated; + + expectTypeOf().toEqualTypeOf>(); + }); + + it('should infer an error message type when the schema describes a primitive type', () => { const primitiveSchema = { type: 'string' } as const; + type Test = FromSchema; + + expectTypeOf().toEqualTypeOf<{ + SchemaError: `Schema must describe an object data structure. Received data type: 'string'`; + }>(); + }); + + it('should infer an error message type when the schema describes an array of primitive types', () => { + const primitiveSchema = { type: 'array', items: { type: 'string' } } as const; + type Test = FromSchema; + + expectTypeOf().toEqualTypeOf<{ + SchemaError: `Schema must describe an object data structure. Received data type: 'string[]'`; + }>(); + }); + + it('should infer an error message type when the schema describes an array of objects', () => { + const primitiveSchema = { + type: 'array', + items: { type: 'object' }, + } as const; + type Test = FromSchema; + + expectTypeOf().toEqualTypeOf<{ + SchemaError: `Schema must describe an object data structure. Received data type: '{ [x: string]: unknown; }[]'`; + }>(); + }); - // @ts-expect-error - Type '{ type: string; }' is not assignable to type '{ type: "object"; }'. + it('should infer an error message type when the schema describes an array of unknown types', () => { + const primitiveSchema = { + type: 'array', + items: {}, + } as const; type Test = FromSchema; - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ + SchemaError: `Schema must describe an object data structure. Received data type: 'unknown[]'`; + }>(); }); it('should infer a Json Schema type', () => { @@ -26,7 +75,9 @@ describe('FromSchema', () => { additionalProperties: false, } as const; - expectTypeOf>().toEqualTypeOf<{ foo: string; bar?: string }>(); + type Test = FromSchema; + + expectTypeOf().toEqualTypeOf<{ foo: string; bar?: string }>(); }); it('should infer a Zod Schema type', () => { @@ -35,22 +86,28 @@ describe('FromSchema', () => { bar: z.string().optional(), }); - expectTypeOf>().toEqualTypeOf<{ foo: string; bar?: string }>(); + type Test = FromSchema; + + expectTypeOf().toEqualTypeOf<{ foo: string; bar?: string }>(); + }); + + it('should infer a Class Schema type', () => { + class TestSchema { + foo: string = 'bar'; + bar?: string; + } + + type Test = FromSchema; + + expectTypeOf().toEqualTypeOf<{ foo: string; bar?: string }>(); }); }); describe('FromSchemaUnvalidated', () => { it('should infer an unknown record type when a generic schema is provided', () => { - expectTypeOf>().toEqualTypeOf>(); - }); + type Test = FromSchemaUnvalidated; - it('should not compile when the schema is primitive', () => { - const primitiveSchema = { type: 'string' } as const; - - // @ts-expect-error - Type '{ type: string; }' is not assignable to type '{ type: "object"; }'. - type Test = FromSchemaUnvalidated; - - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf>(); }); it('should infer a Json Schema type', () => { @@ -63,7 +120,9 @@ describe('FromSchemaUnvalidated', () => { additionalProperties: false, } as const; - expectTypeOf>().toEqualTypeOf<{ foo?: string; bar?: string }>(); + type Test = FromSchemaUnvalidated; + + expectTypeOf().toEqualTypeOf<{ foo?: string; bar?: string }>(); }); it('should infer a Zod Schema type', () => { @@ -72,6 +131,19 @@ describe('FromSchemaUnvalidated', () => { bar: z.string().optional(), }); - expectTypeOf>().toEqualTypeOf<{ foo?: string; bar?: string }>(); + type Test = FromSchemaUnvalidated; + + expectTypeOf().toEqualTypeOf<{ foo?: string; bar?: string }>(); + }); + + it('should infer a Class Schema type', () => { + class TestClassSchema { + foo?: string = 'bar'; + bar?: string; + } + + type Test = FromSchemaUnvalidated; + + expectTypeOf().toEqualTypeOf<{ foo?: string; bar?: string }>(); }); }); diff --git a/packages/framework/src/types/schema.types/base.schema.types.ts b/packages/framework/src/types/schema.types/base.schema.types.ts index 630ba37e9e5..f4f1b3c3134 100644 --- a/packages/framework/src/types/schema.types/base.schema.types.ts +++ b/packages/framework/src/types/schema.types/base.schema.types.ts @@ -1,10 +1,12 @@ import type { InferJsonSchema, JsonSchemaMinimal } from './json.schema.types'; import type { InferZodSchema, ZodSchemaMinimal } from './zod.schema.types'; +import type { InferClassValidatorSchema, ClassValidatorSchema } from './class.schema.types'; +import type { Stringify } from '../util.types'; /** * A schema used to validate a JSON object. */ -export type Schema = JsonSchemaMinimal | ZodSchemaMinimal; +export type Schema = JsonSchemaMinimal | ZodSchemaMinimal | ClassValidatorSchema; /** * Main utility type for schema inference @@ -14,7 +16,32 @@ export type Schema = JsonSchemaMinimal | ZodSchemaMinimal; */ type InferSchema = | InferJsonSchema - | InferZodSchema; + | InferClassValidatorSchema + | InferZodSchema + | never extends infer U + ? /* + * Use a distributive conditional type to detect if all inferred types are `never`. + * When all inferred types are `never`, return an unknown record. + * + * Each schema inference must return `never` type when: + * - The schema is generic (i.e. not a concrete schema type) + * - The schema is not supported (i.e. tried to specify `string` as the schema type) + * - The schema is undefined + * + * @see - https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types + */ + [U] extends [never] + ? // When all inferred types are `never`, return an unknown record. + Record + : // The type inference did not return `never`. Ensure the inferred type is a record type, as only objects are supported. + U extends Record + ? // Got a record type, return it. + U + : // The schema describes a non-record type, return an error message. + { + SchemaError: `Schema must describe an object data structure. Received data type: '${Stringify}'`; + } + : never; /** * Infer the type of a Schema for unvalidated data. diff --git a/packages/framework/src/types/schema.types/class.schema.types.test-d.ts b/packages/framework/src/types/schema.types/class.schema.types.test-d.ts new file mode 100644 index 00000000000..a79c22cd3de --- /dev/null +++ b/packages/framework/src/types/schema.types/class.schema.types.test-d.ts @@ -0,0 +1,80 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { InferClassValidatorSchema, ClassValidatorSchema } from './class.schema.types'; + +describe('ClassSchema types', () => { + class TestSchema { + foo: string = 'bar'; + bar?: string; + } + + describe('validated data', () => { + it('should infer the expected properties for the schema', () => { + type Test = InferClassValidatorSchema; + + expectTypeOf().toEqualTypeOf<{ + foo: string; + bar?: string; + }>(); + }); + + it('should infer an empty object type for an empty object schema', () => { + class EmptySchema {} + type Test = InferClassValidatorSchema; + + expectTypeOf().toEqualTypeOf<{}>(); + }); + + it('should infer to never when the schema is not a ClassSchema', () => { + type Test = InferClassValidatorSchema; + + expectTypeOf().toEqualTypeOf(); + }); + + it('should infer to never when the schema is undefined', () => { + type Test = InferClassValidatorSchema; + + expectTypeOf().toEqualTypeOf(); + }); + + it('should infer to never when the schema is generic', () => { + type Test = InferClassValidatorSchema; + + expectTypeOf().toEqualTypeOf(); + }); + + it('should not compile when a property does not match the expected type', () => { + type Test = InferClassValidatorSchema; + + // @ts-expect-error - Type 'number' is not assignable to type 'string'. + expectTypeOf().toEqualTypeOf<{ + foo: number; + }>(); + }); + + it('should infer to never when the schema includes a constructor', () => { + class TestSchemaWithConstructor { + constructor(public foo: string) {} + + bar?: string; + } + type Test = InferClassValidatorSchema; + + expectTypeOf().toEqualTypeOf(); + }); + }); + + describe('unvalidated data', () => { + /** + * TODO: Support accessing defaulted class properties when Typescript supports it. + */ + it.skip('should keep the defaulted properties optional', () => { + type Test = InferClassValidatorSchema; + + // @ts-expect-error - Type 'undefined' is not assignable to type 'string'. + expectTypeOf().toEqualTypeOf<{ + foo?: string; + bar?: string; + }>(); + }); + }); +}); diff --git a/packages/framework/src/types/schema.types/class.schema.types.ts b/packages/framework/src/types/schema.types/class.schema.types.ts new file mode 100644 index 00000000000..e6154f073d5 --- /dev/null +++ b/packages/framework/src/types/schema.types/class.schema.types.ts @@ -0,0 +1,48 @@ +import { Prettify } from '../util.types'; + +/** + * A type that represents a class. + */ +export type ClassValidatorSchema = new (...args: unknown[]) => T; + +/** + * Extract the properties of a class type. + */ +export type ClassPropsInfer = + T extends ClassValidatorSchema ? Prettify : 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; + * ``` + */ +export type InferClassValidatorSchema = T extends ClassValidatorSchema + ? keyof T extends never + ? // Ensure that a generic schema produces never + never + : // Non-generic schema + Options['validated'] extends true + ? ClassPropsInfer + : // ClassSchema doesn't support default properties, so the resulting type + // will not have default properties set to optional. + ClassPropsInfer + : never; diff --git a/packages/framework/src/types/schema.types/index.ts b/packages/framework/src/types/schema.types/index.ts index b312dd77e4d..bee70f5c10b 100644 --- a/packages/framework/src/types/schema.types/index.ts +++ b/packages/framework/src/types/schema.types/index.ts @@ -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'; diff --git a/packages/framework/src/types/schema.types/json.schema.types.test-d.ts b/packages/framework/src/types/schema.types/json.schema.types.test-d.ts index 4a786aad3a7..192baa6a944 100644 --- a/packages/framework/src/types/schema.types/json.schema.types.test-d.ts +++ b/packages/framework/src/types/schema.types/json.schema.types.test-d.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest'; -import { InferJsonSchema, JsonSchema } from './json.schema.types'; +import { InferJsonSchema, JsonSchema, JsonSchemaMinimal } from './json.schema.types'; describe('JsonSchema types', () => { const testSchema = { @@ -12,30 +12,77 @@ describe('JsonSchema types', () => { } as const satisfies JsonSchema; describe('validated data', () => { - it('should compile when the expected properties are provided', () => { - expectTypeOf>().toEqualTypeOf<{ + it('should infer the expected properties for an object schema with properties', () => { + type Test = InferJsonSchema; + + expectTypeOf().toEqualTypeOf<{ foo: string; bar?: string; }>(); }); - it('should not compile when the schema is not a JsonSchema', () => { - expectTypeOf>().toEqualTypeOf(); + it('should infer the expected properties for a polymorphic schema with properties', () => { + const polymorphicSchema = { + anyOf: [ + { + type: 'object', + properties: { + foo: { type: 'string', default: 'bar' }, + }, + additionalProperties: false, + }, + { + type: 'object', + properties: { + bar: { type: 'number' }, + }, + additionalProperties: false, + }, + ], + } as const satisfies JsonSchema; + + type Test = InferJsonSchema; + + expectTypeOf().toEqualTypeOf< + | { + foo: string; + } + | { + bar?: number; + } + >(); + }); + + it('should infer an empty object type for an empty object schema', () => { + const emptySchema = { type: 'object', properties: {}, additionalProperties: false } as const; + type Test = InferJsonSchema; + + expectTypeOf().toEqualTypeOf<{}>(); + }); + + it('should infer to never when the schema is not a JsonSchema', () => { + type Test = InferJsonSchema; + + expectTypeOf().toEqualTypeOf(); }); - it('should not compile when the schema is generic', () => { - expectTypeOf>().toEqualTypeOf(); + it('should infer to never when the schema is undefined', () => { + type Test = InferJsonSchema; + + expectTypeOf().toEqualTypeOf(); }); - it('should not compile when the schema is a primitive JsonSchema', () => { - const testPrimitiveSchema = { type: 'string' } as const; + it('should infer to never when the schema is generic', () => { + type Test = InferJsonSchema; - expectTypeOf>().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); it('should not compile when a property does not match the expected type', () => { + type Test = InferJsonSchema; + // @ts-expect-error - Type 'number' is not assignable to type 'string'. - expectTypeOf>().toEqualTypeOf<{ + expectTypeOf().toEqualTypeOf<{ foo: number; }>(); }); @@ -43,7 +90,9 @@ describe('JsonSchema types', () => { describe('unvalidated data', () => { it('should keep the defaulted properties optional', () => { - expectTypeOf>().toEqualTypeOf<{ + type Test = InferJsonSchema; + + expectTypeOf().toEqualTypeOf<{ foo?: string; bar?: string; }>(); diff --git a/packages/framework/src/types/schema.types/json.schema.types.ts b/packages/framework/src/types/schema.types/json.schema.types.ts index 71d378beefb..a684729e330 100644 --- a/packages/framework/src/types/schema.types/json.schema.types.ts +++ b/packages/framework/src/types/schema.types/json.schema.types.ts @@ -6,7 +6,11 @@ import type { JSONSchema, FromSchema as JsonSchemaInfer } from 'json-schema-to-t * This type is used to narrow the type of a JSON schema to a minimal type * that is compatible with the `json-schema-to-ts` library. */ -export type JsonSchemaMinimal = { type: 'object' } | { anyOf: unknown[] } | { allOf: unknown[] } | { oneOf: unknown[] }; +export type JsonSchemaMinimal = + | { type: unknown } + | { anyOf: readonly unknown[] } + | { allOf: readonly unknown[] } + | { oneOf: readonly unknown[] }; /** * A JSON schema @@ -43,7 +47,7 @@ export type InferJsonSchema = ? // Secondly, narrow to the JSON schema type to provide type-safety to `json-schema-to-ts` T extends JSONSchema ? Options['validated'] extends true - ? JsonSchemaInfer + ? JsonSchemaInfer : JsonSchemaInfer : never : never; diff --git a/packages/framework/src/types/schema.types/zod.schema.types.test-d.ts b/packages/framework/src/types/schema.types/zod.schema.types.test-d.ts index a85b603f5a7..e937cb3d074 100644 --- a/packages/framework/src/types/schema.types/zod.schema.types.test-d.ts +++ b/packages/framework/src/types/schema.types/zod.schema.types.test-d.ts @@ -2,37 +2,74 @@ import { describe, expectTypeOf, it } from 'vitest'; import { z } from 'zod'; import { InferZodSchema, ZodSchemaMinimal } from './zod.schema.types'; -describe('ZodSchema', () => { +describe('ZodSchema types', () => { const testSchema = z.object({ foo: z.string().default('bar'), bar: z.string().optional(), }); describe('validated data', () => { - it('should compile when the expected properties are provided', () => { - expectTypeOf>().toEqualTypeOf<{ + it('should infer the expected properties for the schema', () => { + type Test = InferZodSchema; + + expectTypeOf().toEqualTypeOf<{ foo: string; bar?: string; }>(); }); - it('should not compile when the schema is not a ZodSchema', () => { - expectTypeOf>().toEqualTypeOf(); + it('should infer the expected properties for a polymorphic schema with properties', () => { + const polymorphicSchema = z.union([ + z.object({ + foo: z.string().default('bar'), + }), + z.object({ + bar: z.number().optional(), + }), + ]); + + type Test = InferZodSchema; + + expectTypeOf().toEqualTypeOf< + | { + foo: string; + } + | { + bar?: number; + } + >(); + }); + + it('should infer an empty object type for an empty object schema', () => { + const emptySchema = z.object({}); + type Test = InferZodSchema; + + expectTypeOf().toEqualTypeOf<{}>(); + }); + + it('should infer to never when the schema is not a ZodSchema', () => { + type Test = InferZodSchema; + + expectTypeOf().toEqualTypeOf(); }); - it('should not compile when the schema is generic', () => { - expectTypeOf>().toEqualTypeOf(); + it('should infer to never when the schema is undefined', () => { + type Test = InferZodSchema; + + expectTypeOf().toEqualTypeOf(); }); - it('should not compile when the schema is a primitive ZodSchema', () => { - const testPrimitiveSchema = z.string(); + it('should infer to never when the schema is generic', () => { + type Test = InferZodSchema; - expectTypeOf>().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); it('should not compile when a property does not match the expected type', () => { + type Test = InferZodSchema; + // @ts-expect-error - Type 'number' is not assignable to type 'string'. - expectTypeOf>().toEqualTypeOf<{ + expectTypeOf().toEqualTypeOf<{ foo: number; }>(); }); @@ -40,7 +77,9 @@ describe('ZodSchema', () => { describe('unvalidated data', () => { it('should keep the defaulted properties optional', () => { - expectTypeOf>().toEqualTypeOf<{ + type Test = InferZodSchema; + + expectTypeOf().toEqualTypeOf<{ foo?: string; bar?: string; }>(); diff --git a/packages/framework/src/types/schema.types/zod.schema.types.ts b/packages/framework/src/types/schema.types/zod.schema.types.ts index efbb6d35604..86bbb40dda8 100644 --- a/packages/framework/src/types/schema.types/zod.schema.types.ts +++ b/packages/framework/src/types/schema.types/zod.schema.types.ts @@ -3,7 +3,7 @@ import type zod from 'zod'; /** * A ZodSchema used to validate a JSON object. */ -export type ZodSchema = zod.ZodType, zod.ZodTypeDef, Record>; +export type ZodSchema = zod.ZodType; /** * A minimal ZodSchema type. diff --git a/packages/framework/src/types/util.types.test-d.ts b/packages/framework/src/types/util.types.test-d.ts index d20a988d93d..46c5cbbbcdf 100644 --- a/packages/framework/src/types/util.types.test-d.ts +++ b/packages/framework/src/types/util.types.test-d.ts @@ -1,5 +1,5 @@ -import { describe, it } from 'vitest'; -import { +import { describe, expectTypeOf, it } from 'vitest'; +import type { ConditionalPartial, Either, Awaitable, @@ -10,6 +10,8 @@ import { Prettify, DeepPartial, DeepRequired, + Stringify, + UnionToTuple, } from './util.types'; describe('Either', () => { @@ -36,188 +38,305 @@ describe('Either', () => { }); describe('Awaitable', () => { - it('should compile when the type is an awaitable', () => { - type TestAwaitable = Awaitable>; - const testAwaitableValid: TestAwaitable = Promise.resolve('bar'); - }); - - it('should compile when the type is not an awaitable', () => { + it('should return a string or a promise of a string', () => { type TestAwaitable = Awaitable; - const testAwaitableValid: TestAwaitable = 'bar'; + expectTypeOf().toEqualTypeOf>(); }); it('should not compile when a non-awaitable type has incorrect properties', () => { type TestAwaitable = Awaitable<{ foo: string }>; // @ts-expect-error - foo should be a string - const testAwaitableInvalid: TestAwaitable = { foo: 123 }; + expectTypeOf().toEqualTypeOf<{ foo: 123 }>(); }); it('should not compile when an awaitable type has incorrect properties', () => { type TestAwaitable = Awaitable<{ foo: string }>; // @ts-expect-error - foo should be a string - const testAwaitableInvalid: TestAwaitable = Promise.resolve({ foo: 123 }); + expectTypeOf().toEqualTypeOf>(); }); }); describe('ConditionalPartial', () => { - it('should compile an empty object when the condition is true', () => { + it('should return an empty object when the condition is true', () => { type TestConditionalPartialTrue = ConditionalPartial<{ foo: string }, true>; - const testConditionalPartialTrueValid: TestConditionalPartialTrue = {}; + expectTypeOf().toEqualTypeOf<{}>(); }); - it('should compile an object with the correct type of properties when the condition is true', () => { + it('should return the object with the optional properties when the condition is true', () => { type TestConditionalPartialTrue = ConditionalPartial<{ foo: string }, true>; - const testConditionalPartialTrueValid: TestConditionalPartialTrue = { foo: 'bar' }; + expectTypeOf().toEqualTypeOf<{ foo?: string }>(); }); it('should not compile an object with the wrong type of properties when the condition is true', () => { type TestConditionalPartialTrue = ConditionalPartial<{ foo: string }, true>; - // @ts-expect-error - foo should be a string - const testConditionalPartialTrueInvalid: TestConditionalPartialTrue = { foo: 123 }; + // @ts-expect-error - foo should be optional + expectTypeOf().toEqualTypeOf<{ foo: 123 }>(); }); - it('should compile an object with the required properties when the condition is false', () => { + it('should return the object with the required properties when the condition is false', () => { type TestConditionalPartialFalse = ConditionalPartial<{ foo: string }, false>; - const testConditionalPartialFalseValid: TestConditionalPartialFalse = { foo: 'bar' }; + expectTypeOf().toEqualTypeOf<{ foo: string }>(); }); it('should not compile an empty object when the condition is false', () => { type TestConditionalPartialFalse = ConditionalPartial<{ foo: string }, false>; // @ts-expect-error: 'foo' is required but missing - const testConditionalPartialFalseInvalid: TestConditionalPartialFalse = {}; + expectTypeOf().toEqualTypeOf<{}>(); }); it('should not compile when the first argument is not an indexable type', () => { // @ts-expect-error - string is not an object type TestConditionalPartialFalse = ConditionalPartial; + expectTypeOf().toEqualTypeOf(); }); }); describe('PickOptional', () => { - it('should compile when the optional property is present', () => { - type TestPickOptional = PickOptional<{ foo?: string }>; - const testPickOptionalValid: TestPickOptional = { foo: 'bar' }; + it('should return the optional property', () => { + type TestPickOptional = PickOptional<{ foo?: string; bar: string }>; + expectTypeOf().toEqualTypeOf<{ foo?: string }>(); }); it('should not compile when the optional property is the wrong type', () => { - type TestPickOptional = PickOptional<{ foo?: string }>; + type TestPickOptional = PickOptional<{ foo?: string; bar: string }>; // @ts-expect-error - foo should be a string - const testPickOptionalInvalid: TestPickOptional = { foo: 123 }; - }); - - it('should compile when the optional property is not present', () => { - type TestPickOptional = PickOptional<{ foo?: string }>; - const testPickOptionalValid: TestPickOptional = {}; + expectTypeOf().toEqualTypeOf<{ foo: 123 }>(); }); it('should not compile when specifying a required property', () => { type TestPickOptional = PickOptional<{ foo?: string; bar: string }>; // @ts-expect-error - bar should not be present - const testPickOptionalInvalid: TestPickOptional = { bar: 'bar' }; + expectTypeOf().toEqualTypeOf<{ foo?: string; bar: string }>(); }); }); describe('PickOptionalKeys', () => { - it('should compile when the optional property is present', () => { + it('should return the optional property', () => { type TestPickOptionalKeys = PickOptionalKeys<{ foo?: string }>; - const testPickOptionalKeysValid: TestPickOptionalKeys = 'foo'; + expectTypeOf().toEqualTypeOf<'foo'>(); }); - it('should not compile when the object has no optional properties', () => { + it('should return never when the object has no optional properties', () => { type TestPickOptionalKeys = PickOptionalKeys<{ foo: string }>; - // @ts-expect-error - no optional property is present - const testPickOptionalKeysInvalid: TestPickOptionalKeys = 'invalid'; + expectTypeOf().toEqualTypeOf(); }); }); describe('PickRequired', () => { - it('should compile when the required property is present', () => { + it('should return the required property', () => { type TestPickRequired = PickRequired<{ foo: string }>; - const testPickRequiredValid: TestPickRequired = { foo: 'bar' }; + expectTypeOf().toEqualTypeOf<{ foo: string }>(); }); it('should not compile when the required property is the wrong type', () => { type TestPickRequired = PickRequired<{ foo: string }>; // @ts-expect-error - foo should be a string - const testPickRequiredInvalid: TestPickRequired = { foo: 123 }; + expectTypeOf().toEqualTypeOf<{ foo: 123 }>(); }); it('should not compile when the required property is not present', () => { type TestPickRequired = PickRequired<{ foo: string }>; // @ts-expect-error - foo should be present - const testPickRequiredInvalid: TestPickRequired = {}; + expectTypeOf().toEqualTypeOf<{}>(); }); it('should not compile when specifying an optional property', () => { type TestPickRequired = PickRequired<{ foo?: string; bar: string }>; // @ts-expect-error - foo should not be present - const testPickRequiredInvalid: TestPickRequired = { foo: 'bar', bar: 'bar' }; + expectTypeOf().toEqualTypeOf<{ foo: string; bar: string }>(); }); }); describe('PickRequiredKeys', () => { - it('should compile when the object is empty', () => { + it('should return the required property', () => { type TestPickRequiredKeys = PickRequiredKeys<{ foo: string }>; - const testPickRequiredKeysValid: TestPickRequiredKeys = 'foo'; + expectTypeOf().toEqualTypeOf<'foo'>(); }); - it('should not compile when the object has no required properties', () => { + it('should return never when the object has no required properties', () => { type TestPickRequiredKeys = PickRequiredKeys<{ foo?: string }>; - // @ts-expect-error - no required property is present - const testPickRequiredKeysInvalid: TestPickRequiredKeys = 'invalid'; + expectTypeOf().toEqualTypeOf(); }); }); describe('Prettify', () => { - it('should compile the prettified type to the identity type', () => { + it('should return the identity type', () => { type TestPrettify = Prettify<{ foo: string }>; - const testPrettifyValid: TestPrettify = { foo: 'bar' }; + expectTypeOf().toEqualTypeOf<{ foo: string }>(); }); it('should not compile when the object has incorrect properties', () => { type TestPrettify = Prettify<{ foo: string }>; // @ts-expect-error - foo should be a string - const testPrettifyInvalid: TestPrettify = { foo: 123 }; + expectTypeOf().toEqualTypeOf<{ foo: 123 }>(); }); }); describe('DeepPartial', () => { it('should make a top-level property optional', () => { type TestDeepPartial = DeepPartial<{ foo: string }>; - const testDeepPartialValid: TestDeepPartial = { foo: undefined }; + expectTypeOf().toEqualTypeOf<{ foo?: string }>(); }); it('should make a nested property optional', () => { type TestDeepPartial = DeepPartial<{ foo: { bar: string } }>; - const testDeepPartialValid: TestDeepPartial = { foo: { bar: undefined } }; + expectTypeOf().toEqualTypeOf<{ foo?: { bar?: string } }>(); }); }); describe('DeepRequired', () => { it('should make a top-level property required', () => { type TestDeepRequired = DeepRequired<{ foo?: string }>; - const testDeepRequiredValid: TestDeepRequired = { foo: 'bar' }; + expectTypeOf().toEqualTypeOf<{ foo: string }>(); }); it('should make a nested object property required', () => { type TestDeepRequired = DeepRequired<{ foo: { bar?: string } }>; - const testDeepRequiredValid: TestDeepRequired = { foo: { bar: 'bar' } }; + expectTypeOf().toEqualTypeOf<{ foo: { bar: string } }>(); }); it('should make a nested array property required', () => { type TestDeepRequired = DeepRequired<{ foo: { bar: (string | undefined)[] } }>; - const testDeepRequiredValid: TestDeepRequired = { foo: { bar: ['bar'] } }; + expectTypeOf().toEqualTypeOf<{ foo: { bar: string[] } }>(); }); it('should not compile when the array has incorrect properties', () => { type TestDeepRequired = DeepRequired<{ foo: { bar: (string | undefined)[] } }>; // @ts-expect-error - bar should be an array of strings - const testDeepRequiredInvalid: TestDeepRequired = { foo: { bar: [undefined] } }; + expectTypeOf().toEqualTypeOf<{ foo: { bar: undefined[] } }>(); }); it('should not compile when the object has incorrect properties', () => { type TestDeepRequired = DeepRequired<{ foo: string }>; // @ts-expect-error - foo should be a string - const testDeepRequiredInvalid: TestDeepRequired = { foo: 123 }; + expectTypeOf().toEqualTypeOf<{ foo: 123 }>(); + }); +}); + +describe('UnionToTuple', () => { + it('should return a tuple of the union types', () => { + type TestUnionToTuple = UnionToTuple<1 | 2>; + // UnionToTuple can return items in any order, so we need to check that the array contains the expected items + type Parts = 1 | 2; + expectTypeOf().toMatchTypeOf<[Parts, Parts]>(); + }); +}); + +describe('Stringify', () => { + it('should stringify a string type', () => { + type TestStringify = Stringify; + expectTypeOf().toEqualTypeOf<'string'>(); + }); + + it('should stringify a boolean type', () => { + type TestStringify = Stringify; + expectTypeOf().toEqualTypeOf<'boolean'>(); + }); + + it('should stringify a number type', () => { + type TestStringify = Stringify; + expectTypeOf().toEqualTypeOf<'number'>(); + }); + + it('should stringify a bigint type', () => { + type TestStringify = Stringify; + expectTypeOf().toEqualTypeOf<'bigint'>(); + }); + + it('should stringify an unknown type', () => { + type TestStringify = Stringify; + expectTypeOf().toEqualTypeOf<'unknown'>(); + }); + + it('should stringify an undefined type', () => { + type TestStringify = Stringify; + expectTypeOf().toEqualTypeOf<'undefined'>(); + }); + + it('should stringify a null type', () => { + type TestStringify = Stringify; + expectTypeOf().toEqualTypeOf<'null'>(); + }); + + it('should stringify a symbol type', () => { + type TestStringify = Stringify; + expectTypeOf().toEqualTypeOf<'symbol'>(); + }); + + it('should stringify an array type', () => { + type TestStringify = Stringify>; + expectTypeOf().toEqualTypeOf<'string[]'>(); + }); + + it('should stringify an empty object type', () => { + type TestStringify = Stringify<{}>; + expectTypeOf().toEqualTypeOf<'{}'>(); + }); + + it('should stringify an `object` type', () => { + type TestStringify = Stringify; + expectTypeOf().toEqualTypeOf<'{}'>(); + }); + + it('should stringify a `Record` type', () => { + type TestStringify = Stringify>; + expectTypeOf().toEqualTypeOf<'{ [x: string]: never; }'>(); + }); + + it('should stringify a `Record` type', () => { + type TestStringify = Stringify>; + expectTypeOf().toEqualTypeOf<'{ [x: string]: string; }'>(); + }); + + it('should stringify a `{ [x: string]: string }` type', () => { + type TestStringify = Stringify<{ [x: string]: string }>; + expectTypeOf().toEqualTypeOf<'{ [x: string]: string; }'>(); + }); + + it('should stringify a `{ [x: string]: unknown }` type', () => { + type TestStringify = Stringify<{ [x: string]: unknown }>; + expectTypeOf().toEqualTypeOf<'{ [x: string]: unknown; }'>(); + }); + + it('should stringify a `Record` type', () => { + type TestStringify = Stringify>; + expectTypeOf().toEqualTypeOf<'{ [x: string]: unknown; }'>(); + }); + + it('should stringify an array of empty object types', () => { + type TestStringify = Stringify>; + expectTypeOf().toEqualTypeOf<'{}[]'>(); + }); + + it('should stringify an object type with a single required property', () => { + type TestStringify = Stringify<{ foo: string }>; + expectTypeOf().toEqualTypeOf<'{ foo: string; }'>(); + }); + + it('should stringify an object type with a single optional property', () => { + type TestStringify = Stringify<{ foo?: string }>; + expectTypeOf().toEqualTypeOf<'{ foo?: string; }'>(); + }); + + it('should stringify an object type with multiple properties', () => { + type TestStringify = Stringify<{ foo: string; bar?: number }>; + // The order of the properties is not guaranteed, so we need to check that the string matches the expected pattern + type Parts = `foo: string;` | `bar?: number;`; + expectTypeOf().toMatchTypeOf<`{ ${Parts} ${Parts} }`>(); + }); + + it('should stringify an object type with a nested object property', () => { + type TestStringify = Stringify<{ foo: { bar: string } }>; + expectTypeOf().toEqualTypeOf<'{ foo: { bar: string; }; }'>(); + }); + + it('should stringify an object type with an array property', () => { + type TestStringify = Stringify<{ foo: { bar: string }[] }>; + expectTypeOf().toEqualTypeOf<'{ foo: { bar: string; }[]; }'>(); + }); + + it('should stringify an array of unknown record types', () => { + type TestStringify = Stringify<{ [x: string]: unknown }[]>; + expectTypeOf().toEqualTypeOf<'{ [x: string]: unknown; }[]'>(); }); }); diff --git a/packages/framework/src/types/util.types.ts b/packages/framework/src/types/util.types.ts index 0e5fc0a8f9a..ab770d9d979 100644 --- a/packages/framework/src/types/util.types.ts +++ b/packages/framework/src/types/util.types.ts @@ -24,7 +24,11 @@ export type Prettify = { [K in keyof T]: T[K] } & {}; /** * Mark properties of T as optional if Condition is true */ -export type ConditionalPartial = Condition extends true ? Partial : T; +export type ConditionalPartial = T extends Obj + ? Condition extends true + ? Partial + : T + : never; /** * Same as Nullable except without `null`. @@ -107,3 +111,152 @@ export type DeepRequired = T extends object [P in keyof T]-?: DeepRequired; } : T; + +// https://github.com/type-challenges/type-challenges/issues/737 +/** + * Convert union type T to an intersection type. + */ +type UnionToIntersection = (T extends unknown ? (x: T) => unknown : never) extends (x: infer U) => unknown + ? U + : never; + +/* + * Get the last union type in a union T. + * + * ((x: A) => unknown) & ((x: B) => unknown) is overloaded function then Conditional types are inferred only from the last overload + * @see https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#type-inference-in-conditional-types + * + * + * @example + * ```ts + * type Test = LastUnion<1 | 2>; // => 2 + * ``` + */ +type LastUnion = + UnionToIntersection unknown : never> extends (x: infer L) => unknown ? L : never; + +/** + * Convert a union type to a tuple. + */ +export type UnionToTuple> = [T] extends [never] ? [] : [...UnionToTuple>, Last]; + +/** + * Stringify type T to a string. Useful for error messages. + * + * Each built-in type is exhaustively handled to produce a string representation. + * + * @example + * ```ts + * type Test = Stringify; + * // => 'string' + * ``` + * + * @example + * ```ts + * type Test = Stringify<{ foo: string; bar?: number }>; + * // => '{ foo: string; bar?: number }' + * ``` + * + * @example + * ```ts + * type Test = Stringify>; + * // => 'unknown[]' + * ``` + */ +export type Stringify = T extends string + ? 'string' + : T extends number + ? 'number' + : T extends boolean + ? 'boolean' + : T extends bigint + ? 'bigint' + : T extends symbol + ? 'symbol' + : T extends null + ? 'null' + : T extends undefined + ? 'undefined' + : T extends Array + ? `${Stringify}[]` + : T extends Obj + ? `{${DeepStringifyObject}}` + : T extends unknown + ? 'unknown' + : // Fallback to `never` for unknown types + 'never'; + +/** + * Known types that can be used to stringify a type. + */ +type KnownTypes = string | number | boolean | bigint | symbol | undefined | null | Array | Obj; + +/** + * Check if T is a dictionary type. + * + * @example + * ```ts + * type Test1 = IsDictionary>; // true + * type Test2 = IsDictionary>; // true + * type Test3 = IsDictionary<{ [x: string]: string }>; // true + * type Test4 = IsDictionary<{ [x: string]: unknown }>; // true + * type Test5 = IsDictionary<{ foo: string }>; // false + * ``` + */ +type IsDictionary = string extends keyof T ? true : false; + +/** + * The key used when the key is for a dictionary type. + */ +type UnknownKey = '[x: string]'; + +/** + * Stringify the properties of a record type. + */ +type DeepStringifyObject = + // If T has no keys, return an empty string + keyof T extends never + ? '' + : // Convert the keys of T into a tuple and destructure it into the first key (U) and the rest (Rest) + UnionToTuple extends [infer U, ...infer Rest] + ? ` ${ + // If U is a string, construct the string representation of the key-value pair + U extends keyof T & string + ? `${ + // Build the key. If T is a dictionary, use "[key: string]" as the key. Otherwise, use the key directly. + IsDictionary extends true ? UnknownKey : U + }${ + // Build the optional "?" indicator. Check if the value extends undefined + undefined extends T[U] + ? // Check if the value extends a known type + T[U] extends KnownTypes + ? // If the value extends a known type, add a "?" to the end of the key + '?' + : // Otherwise, the value is not a known type (i.e. `unknown`), so we don't add a "?" + '' + : // The value didn't extend undefined, so no "?" is needed + '' + }: ${ + // Build the value. + T[U] extends never + ? // If the value is `never`, return `never` + 'never' + : // Otherwise, stringify the value, excluding undefined if necessary + Stringify< + // Check if the value is optional + Exclude extends never + ? // If the value was explicitly undefined, return undefined + undefined + : // Otherwise, remove undefined from the type as it's already handled with the "?" + Exclude + > + };${ + // Add a space if there are no more keys to process in the object + Rest extends [] ? ' ' : '' + }` + : '' + }${ + // Recursively process the rest of the keys, excluding string, number, and symbol to handle cases like `{ [x: string]: unknown }` whose keys are `string | number` + DeepStringifyObject> + }` + : never; diff --git a/packages/framework/src/utils/http.utils.ts b/packages/framework/src/utils/http.utils.ts index 361e57c5cfa..342c346ea79 100644 --- a/packages/framework/src/utils/http.utils.ts +++ b/packages/framework/src/utils/http.utils.ts @@ -1,4 +1,4 @@ -import { checkIsResponseError } from '@novu/shared'; +import { checkIsResponseError } from './platform.utils'; import { BridgeError, MissingSecretKeyError, PlatformError } from '../errors'; export const initApiClient = (secretKey: string, apiUrl: string) => { diff --git a/packages/framework/src/utils/platform.utils.ts b/packages/framework/src/utils/platform.utils.ts new file mode 100644 index 00000000000..3c7303c21e8 --- /dev/null +++ b/packages/framework/src/utils/platform.utils.ts @@ -0,0 +1,8 @@ +import type { IResponseError } from '@novu/shared'; + +/** + * Validate (type-guard) that an error response matches our IResponseError interface. + */ +export const checkIsResponseError = (err: unknown): err is IResponseError => { + return !!err && typeof err === 'object' && 'error' in err && 'message' in err && 'statusCode' in err; +}; diff --git a/packages/framework/src/validators/base.validator.ts b/packages/framework/src/validators/base.validator.ts index 96acecdb135..bb9eb3c3c0a 100644 --- a/packages/framework/src/validators/base.validator.ts +++ b/packages/framework/src/validators/base.validator.ts @@ -1,9 +1,18 @@ -import type { FromSchema, FromSchemaUnvalidated, Schema, JsonSchema, ZodSchema } from '../types/schema.types'; +import type { + FromSchema, + FromSchemaUnvalidated, + Schema, + JsonSchema, + ZodSchema, + ClassValidatorSchema, +} 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(); /** @@ -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); } @@ -44,6 +55,8 @@ export const validateData = async < export const transformSchema = async (schema: Schema): Promise => { 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); } diff --git a/packages/framework/src/validators/class-validator.validator.ts b/packages/framework/src/validators/class-validator.validator.ts new file mode 100644 index 00000000000..f098ca0ce15 --- /dev/null +++ b/packages/framework/src/validators/class-validator.validator.ts @@ -0,0 +1,173 @@ +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') { + if (schema.type === 'object') { + // eslint-disable-next-line no-param-reassign + schema.additionalProperties = false; + } + + // If schema has properties, recursively apply the function to each property + if (schema.properties) { + for (const key in schema.properties) { + if (schema.properties.hasOwnProperty(key)) { + addAdditionalPropertiesFalse(schema.properties[key]); + } + } + } + + // If schema has items (meaning it's an array), apply recursively to each item schema + if (schema.type === 'array' && schema.items) { + addAdditionalPropertiesFalse(schema.items); + } + } + + 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 { + 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 + // eslint-disable-next-line import/extensions + import: import('class-transformer/cjs/storage.js'), + exports: ['defaultMetadataStorage'], + }, + { + name: 'reflect-metadata', + import: import('reflect-metadata'), + exports: [], + }, + { + name: 'class-validator-jsonschema', + import: import('class-validator-jsonschema'), + exports: ['validationMetadatasToSchemas', 'targetConstructorToSchema'], + }, + ]; + + async canHandle(schema: Schema): Promise { + 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_Validated = FromSchema, + >(data: T_Unvalidated, schema: T_Schema): Promise> { + 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 { + /* + * 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 + // eslint-disable-next-line import/extensions + const { defaultMetadataStorage } = await import('class-transformer/cjs/storage.js'); + 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; + } +} diff --git a/packages/framework/src/validators/fixures/class-validator.fixtures.ts b/packages/framework/src/validators/fixures/class-validator.fixtures.ts new file mode 100644 index 00000000000..c26137fc6ea --- /dev/null +++ b/packages/framework/src/validators/fixures/class-validator.fixtures.ts @@ -0,0 +1,66 @@ +import 'reflect-metadata'; +import { IsBoolean, IsEnum, IsIn, IsNumber, IsString, ValidateIf, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +enum TestEnum { + A = 'A', + B = 'B', + C = 'C', +} + +export class StringSchema { + @IsString() + name!: string; +} + +class NestedChildrenSchema { + @IsNumber() + age!: number; +} + +export class NestedSchema { + @IsString() + name!: string; + + @ValidateNested() + @Type(() => NestedChildrenSchema) + nested!: NestedChildrenSchema; +} +export class NestedArraySchema { + @IsString() + name!: string; + + @ValidateNested({ each: true }) + @Type(() => NestedChildrenSchema) + nested!: NestedChildrenSchema[]; +} + +export class StringAndNumberSchema { + @IsString() + name!: string; + @IsNumber() + age!: number; +} + +export class SimpleTestEnumSchema { + @IsString() + @IsEnum(TestEnum) + enum?: TestEnum; +} + +export class UnionSchema { + @IsIn(['stringType', 'numberType', 'booleanType']) + type!: 'stringType' | 'numberType' | 'booleanType'; + + @ValidateIf((obj) => obj.type === 'stringType') + @IsString() + stringVal?: string; + + @ValidateIf((obj) => obj.type === 'numberType') + @IsNumber() + numVal?: number; + + @ValidateIf((obj) => obj.type === 'booleanType') + @IsBoolean() + boolVal?: boolean; +} diff --git a/packages/framework/src/validators/validator.test.ts b/packages/framework/src/validators/validator.test.ts index b43a32c0316..33c48157537 100644 --- a/packages/framework/src/validators/validator.test.ts +++ b/packages/framework/src/validators/validator.test.ts @@ -1,9 +1,17 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; import { validateData, transformSchema } from './base.validator'; -import { Schema, ZodSchema, JsonSchema } from '../types/schema.types'; +import type { Schema, ZodSchema, JsonSchema, ClassValidatorSchema } from '../types/schema.types'; +import { + StringSchema, + NestedSchema, + StringAndNumberSchema, + NestedArraySchema, + SimpleTestEnumSchema, + UnionSchema, +} from './fixures/class-validator.fixtures'; -const schemas = ['zod', 'json'] as const; +const schemas = ['zod', 'class', 'json'] as const; describe('validators', () => { describe('validateData', () => { @@ -11,6 +19,7 @@ describe('validators', () => { title: string; schemas: { zod: ZodSchema | null; + class: ClassValidatorSchema | null; json: JsonSchema; }; payload: Record; @@ -19,6 +28,7 @@ describe('validators', () => { data?: Record; errors?: { zod: { message: string; path: string }[] | null; + class: { message: string; path: string }[] | null; json: { message: string; path: string }[]; }; }; @@ -28,6 +38,7 @@ describe('validators', () => { title: 'should successfully validate data', schemas: { zod: z.object({ name: z.string() }), + class: StringSchema, json: { type: 'object', properties: { name: { type: 'string' } } } as const, }, payload: { name: 'John' }, @@ -40,6 +51,7 @@ describe('validators', () => { title: 'should remove additional properties and successfully validate', schemas: { zod: z.object({ name: z.string() }), + class: StringSchema, json: { type: 'object', properties: { name: { type: 'string' } }, additionalProperties: false } as const, }, payload: { name: 'John', age: 30 }, @@ -52,6 +64,7 @@ describe('validators', () => { title: 'should return errors when given invalid types', schemas: { zod: z.object({ name: z.string() }), + class: StringSchema, json: { type: 'object', properties: { name: { type: 'string' } } } as const, }, payload: { name: 123 }, @@ -60,6 +73,7 @@ describe('validators', () => { errors: { // TODO: error normalization json: [{ message: 'must be string', path: '/name' }], + class: [{ message: 'name must be a string', path: '/name' }], zod: [{ message: 'Expected string, received number', path: '/name' }], }, }, @@ -68,6 +82,7 @@ describe('validators', () => { title: 'should validate nested properties successfully', schemas: { zod: z.object({ name: z.string(), nested: z.object({ age: z.number() }) }), + class: NestedSchema, json: { type: 'object', properties: { @@ -86,6 +101,7 @@ describe('validators', () => { title: 'should return errors for invalid nested properties', schemas: { zod: z.object({ name: z.string(), nested: z.object({ age: z.number() }) }), + class: NestedSchema, json: { type: 'object', properties: { @@ -99,14 +115,80 @@ describe('validators', () => { success: false, errors: { zod: [{ message: 'Expected number, received string', path: '/nested/age' }], + class: [{ message: 'age must be a number conforming to the specified constraints', path: '/nested/age' }], json: [{ message: 'must be number', path: '/nested/age' }], }, }, }, + { + title: 'should validate nested array objects successfully', + schemas: { + zod: z.object({ name: z.string(), nested: z.array(z.object({ age: z.number() })) }), + class: NestedArraySchema, + json: { + type: 'object', + properties: { + name: { type: 'string' }, + nested: { + type: 'array', + items: { + type: 'object', + properties: { + age: { + type: 'number', + }, + }, + required: ['age'], + }, + }, + }, + } as const, + }, + payload: { name: 'John', nested: [{ age: 30 }] }, + result: { + success: true, + data: { name: 'John', nested: [{ age: 30 }] }, + }, + }, + { + title: 'should return errors for invalid nested array objects', + schemas: { + zod: z.object({ name: z.string(), nested: z.array(z.object({ age: z.number() })) }), + class: NestedArraySchema, + json: { + type: 'object', + properties: { + name: { type: 'string' }, + nested: { + type: 'array', + items: { + type: 'object', + properties: { + age: { + type: 'number', + }, + }, + required: ['age'], + }, + }, + }, + } as const, + }, + payload: { name: 'John', nested: [{ age: '30' }] }, + result: { + success: false, + errors: { + zod: [{ message: 'Expected number, received string', path: '/nested/0/age' }], + class: [{ message: 'age must be a number conforming to the specified constraints', path: '/nested/0/age' }], + json: [{ message: 'must be number', path: '/nested/0/age' }], + }, + }, + }, { title: 'should successfully validate a polymorphic oneOf schema', schemas: { zod: null, // Zod has no support for `oneOf` + class: null, // ClassValidator has no support for `oneOf` json: { oneOf: [ { type: 'object', properties: { stringType: { type: 'string' } }, required: ['stringType'] }, @@ -129,6 +211,7 @@ describe('validators', () => { title: 'should return errors for invalid polymorphic oneOf schema', schemas: { zod: null, // Zod has no support for `oneOf` + class: null, // ClassValidator has no support for `oneOf` json: { oneOf: [ { type: 'object', properties: { stringType: { type: 'string' } }, required: ['stringType'] }, @@ -146,6 +229,7 @@ describe('validators', () => { errors: { json: [{ message: 'must match exactly one schema in oneOf', path: '' }], zod: null, // Zod has no support for `oneOf` + class: null, // ClassValidator has no support for `oneOf` }, }, }, @@ -153,6 +237,7 @@ describe('validators', () => { title: 'should successfully validate a polymorphic allOf schema', schemas: { zod: null, // Zod has no support for `oneOf` + class: null, // ClassValidator has no support for `oneOf` json: { allOf: [ { type: 'object', properties: { stringType: { type: 'string' } }, required: ['stringType'] }, @@ -179,6 +264,7 @@ describe('validators', () => { title: 'should return errors for invalid polymorphic `allOf` schema', schemas: { zod: null, // Zod has no support for `allOf` + class: null, // ClassValidator has no support for `allOf` json: { allOf: [ { type: 'object', properties: { stringType: { type: 'string' } }, required: ['stringType'] }, @@ -195,6 +281,7 @@ describe('validators', () => { errors: { json: [{ message: "must have required property 'numberType'", path: '' }], zod: null, // Zod has no support for `allOf` + class: null, // ClassValidator has no support for `allOf` }, }, }, @@ -206,6 +293,7 @@ describe('validators', () => { z.object({ type: z.literal('numberType'), numVal: z.number() }), z.object({ type: z.literal('booleanType'), boolVal: z.boolean() }), ]), + class: UnionSchema, json: { anyOf: [ { @@ -243,6 +331,7 @@ describe('validators', () => { z.object({ type: z.literal('numberType'), numVal: z.number() }), z.object({ type: z.literal('booleanType'), boolVal: z.boolean() }), ]), + class: UnionSchema, json: { anyOf: [ { @@ -271,6 +360,7 @@ describe('validators', () => { success: false, errors: { zod: [{ message: 'Expected number, received string', path: '/numVal' }], + class: [{ message: 'numVal must be a number conforming to the specified constraints', path: '/numVal' }], /* * TODO: use discriminator to get the correct error message. * @@ -306,6 +396,59 @@ describe('validators', () => { }, }, }, + { + title: 'should successfully validate enum property', + schemas: { + zod: z.object({ enum: z.enum(['A', 'B', 'C']) }), + class: SimpleTestEnumSchema, + json: { + type: 'object', + properties: { + enum: { + type: 'string', + enum: ['A', 'B', 'C'], + }, + }, + required: ['enum'], + } as const, + }, + payload: { enum: 'A' }, + result: { + success: true, + data: { enum: 'A' }, + }, + }, + { + title: 'should return errors for invalid enum property', + schemas: { + zod: z.object({ enum: z.enum(['A', 'B', 'C']) }), + class: SimpleTestEnumSchema, + json: { + type: 'object', + properties: { + enum: { + type: 'string', + enum: ['A', 'B', 'C'], + }, + }, + required: ['enum'], + } as const, + }, + payload: { enum: 'Z' }, + result: { + success: false, + errors: { + zod: [{ message: "Invalid enum value. Expected 'A' | 'B' | 'C', received 'Z'", path: '/enum' }], + class: [{ message: 'enum must be one of the following values: A, B, C', path: '/enum' }], + json: [ + { + message: 'must be equal to one of the allowed values', + path: '/enum', + }, + ], + }, + }, + }, ]; schemas.forEach((schema) => { @@ -338,6 +481,7 @@ describe('validators', () => { title: string; schemas: { zod: ZodSchema | null; + class: ClassValidatorSchema | null; json: JsonSchema; }; result: JsonSchema; @@ -347,6 +491,7 @@ describe('validators', () => { title: 'should transform a simple object schema', schemas: { zod: z.object({ name: z.string(), age: z.number() }), + class: StringAndNumberSchema, json: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'number' } }, @@ -365,6 +510,7 @@ describe('validators', () => { title: 'should transform a nested object schema', schemas: { zod: z.object({ name: z.string(), nested: z.object({ age: z.number() }) }), + class: NestedSchema, json: { type: 'object', properties: { @@ -395,10 +541,60 @@ describe('validators', () => { additionalProperties: false, }, }, + { + title: 'should transform a nested array object schema', + schemas: { + zod: z.object({ name: z.string(), nested: z.array(z.object({ age: z.number() })) }), + class: NestedArraySchema, + json: { + type: 'object', + properties: { + name: { type: 'string' }, + nested: { + type: 'array', + items: { + type: 'object', + properties: { + age: { + type: 'number', + }, + }, + required: ['age'], + additionalProperties: false, + }, + }, + }, + required: ['name', 'nested'], + additionalProperties: false, + } as const, + }, + result: { + type: 'object', + properties: { + name: { type: 'string' }, + nested: { + type: 'array', + items: { + type: 'object', + properties: { + age: { + type: 'number', + }, + }, + required: ['age'], + additionalProperties: false, + }, + }, + }, + required: ['name', 'nested'], + additionalProperties: false, + }, + }, { title: 'should transform a polymorphic `oneOf` schema', schemas: { zod: null, // Zod has no support for `oneOf` + class: null, // ClassValidator has no support for `oneOf` json: { oneOf: [ { type: 'object', properties: { stringType: { type: 'string' } }, required: ['stringType'] }, @@ -419,6 +615,7 @@ describe('validators', () => { title: 'should transform a polymorphic `allOf` schema', schemas: { zod: null, // Zod has no support for `anyOf` + class: null, // ClassValidator has no support for `anyOf` json: { allOf: [ { type: 'object', properties: { stringType: { type: 'string' } }, required: ['stringType'] }, @@ -447,6 +644,7 @@ describe('validators', () => { ]) ), }), + class: null, // ClassValidator has no support for `anyOf` json: { type: 'object', properties: { @@ -513,6 +711,33 @@ describe('validators', () => { required: ['elements'], }, }, + { + title: 'should transform a enum schema', + schemas: { + zod: z.object({ enum: z.enum(['A', 'B', 'C']) }), + class: SimpleTestEnumSchema, // ClassValidator has no support for `anyOf` + json: { + type: 'object', + properties: { + enum: { + type: 'string', + enum: ['A', 'B', 'C'], + }, + }, + required: ['enum'], + } as const, + }, + result: { + type: 'object', + properties: { + enum: { + type: 'string', + enum: ['A', 'B', 'C'], + }, + }, + required: ['enum'], + }, + }, ]; schemas.forEach((schema) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e391b2fda1..44c2e7a0b5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3578,6 +3578,15 @@ importers: aws-lambda: specifier: ^1.0.7 version: 1.0.7 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.1 + class-validator-jsonschema: + specifier: ^5.0.1 + version: 5.0.1(class-transformer@0.5.1)(class-validator@0.14.1) express: specifier: ^4.19.2 version: 4.19.2 @@ -3593,6 +3602,9 @@ importers: prettier: specifier: ^3.2.5 version: 3.3.2 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.12))(@types/node@20.16.5)(typescript@5.6.2) @@ -20109,6 +20121,12 @@ packages: resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==} engines: {node: '>=0.10.0'} + class-validator-jsonschema@5.0.1: + resolution: {integrity: sha512-9uTdo5jSnJUj7f0dS8YZDqM0Fv1Uky0BWefswnNa2F4nRcKPCiEb5z3nDUaXyEzcERCrizE+0AGDSao1uSNX9g==} + peerDependencies: + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.14.0 + class-validator@0.14.1: resolution: {integrity: sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==} @@ -26343,6 +26361,9 @@ packages: lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -28200,6 +28221,9 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openapi3-ts@3.2.0: + resolution: {integrity: sha512-/ykNWRV5Qs0Nwq7Pc0nJ78fgILvOT/60OxEmB3v7yQ8a8Bwcm43D4diaYazG/KBn6czA+52XYy931WFLMCUeSg==} + opener@1.5.2: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true @@ -31047,6 +31071,9 @@ packages: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + reflect-metadata@0.1.14: + resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -61803,6 +61830,16 @@ snapshots: isobject: 3.0.1 static-extend: 0.1.2 + class-validator-jsonschema@5.0.1(class-transformer@0.5.1)(class-validator@0.14.1): + dependencies: + class-transformer: 0.5.1 + class-validator: 0.14.1 + lodash.groupby: 4.6.0 + lodash.merge: 4.6.2 + openapi3-ts: 3.2.0 + reflect-metadata: 0.1.14 + tslib: 2.7.0 + class-validator@0.14.1: dependencies: '@types/validator': 13.12.1 @@ -70710,6 +70747,8 @@ snapshots: lodash.get@4.4.2: {} + lodash.groupby@4.6.0: {} + lodash.includes@4.3.0: {} lodash.isarguments@3.1.0: {} @@ -73525,6 +73564,10 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openapi3-ts@3.2.0: + dependencies: + yaml: 2.5.0 + opener@1.5.2: {} opentracing@0.14.7: {} @@ -77234,6 +77277,8 @@ snapshots: dependencies: '@eslint-community/regexpp': 4.11.0 + reflect-metadata@0.1.14: {} + reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.6: