From b9185edd7b63e0da742442f08ced4e294463ed82 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 11 Nov 2024 21:24:33 +0100 Subject: [PATCH] fix(framework): Ensure missing schemas return unknown record type (#6912) --- .../construct-framework-workflow.usecase.ts | 8 +- packages/framework/package.json | 2 +- packages/framework/src/client.ts | 2 +- .../framework/src/client.validation.test.ts | 387 +++--------------- .../src/constants/error.constants.ts | 1 + packages/framework/src/errors/base.errors.ts | 2 +- .../framework/src/errors/import.errors.ts | 16 + .../workflow/discover-action-step-factory.ts | 4 +- .../workflow/discover-channel-step-factory.ts | 14 +- .../workflow/discover-custom-step-factory.ts | 12 +- .../resources/workflow/discover-providers.ts | 9 +- .../workflow/workflow.resource.test-d.ts | 241 +++++++++++ .../resources/workflow/workflow.resource.ts | 2 +- .../src/schemas/providers/chat/index.ts | 4 +- .../schemas/providers/chat/slack.schema.ts | 4 +- .../src/schemas/providers/email/index.ts | 4 +- .../schemas/providers/email/mailgun.schema.ts | 4 +- .../schemas/providers/email/mailjet.schema.ts | 10 +- .../providers/email/nodemailer.schema.ts | 8 +- .../providers/email/novu-email.schema.ts | 4 +- .../providers/email/sendgrid.schema.ts | 4 +- .../src/schemas/providers/generic.schema.ts | 4 +- .../src/schemas/providers/inApp/index.ts | 4 +- .../providers/inApp/novu-inapp.schema.ts | 4 +- .../framework/src/schemas/providers/index.ts | 4 +- .../src/schemas/providers/push/apns.schema.ts | 6 +- .../src/schemas/providers/push/expo.schema.ts | 4 +- .../src/schemas/providers/push/fcm.schema.ts | 4 +- .../src/schemas/providers/push/index.ts | 4 +- .../providers/push/one-signal.schema.ts | 4 +- .../src/schemas/providers/sms/index.ts | 4 +- .../schemas/providers/sms/novu-sms.schema.ts | 4 +- .../schemas/providers/sms/twilio.schema.ts | 4 +- .../src/schemas/steps/actions/delay.schema.ts | 6 +- .../schemas/steps/actions/digest.schema.ts | 10 +- .../src/schemas/steps/actions/index.ts | 4 +- .../src/schemas/steps/channels/chat.schema.ts | 6 +- .../schemas/steps/channels/email.schema.ts | 6 +- .../schemas/steps/channels/in-app.schema.ts | 10 +- .../src/schemas/steps/channels/index.ts | 4 +- .../src/schemas/steps/channels/push.schema.ts | 6 +- .../src/schemas/steps/channels/sms.schema.ts | 6 +- .../src/schemas/steps/empty.schema.ts | 4 +- .../src/schemas/steps/trigger.schema.ts | 6 +- packages/framework/src/types/import.types.ts | 39 ++ packages/framework/src/types/schema.types.ts | 67 --- .../schema.types/base.schema.types.test-d.ts | 77 ++++ .../types/schema.types/base.schema.types.ts | 45 ++ .../framework/src/types/schema.types/index.ts | 3 + .../schema.types/json.schema.types.test-d.ts | 52 +++ .../types/schema.types/json.schema.types.ts | 49 +++ .../schema.types/zod.schema.types.test-d.ts | 49 +++ .../types/schema.types/zod.schema.types.ts | 45 ++ packages/framework/src/types/util.types.ts | 5 + .../framework/src/types/validator.types.ts | 12 +- .../framework/src/utils/import.utils.test.ts | 59 +++ packages/framework/src/utils/import.utils.ts | 38 ++ .../src/validators/base.validator.ts | 36 +- .../src/validators/json-schema.validator.ts | 30 +- .../src/validators/validator.test.ts | 4 +- .../framework/src/validators/zod.validator.ts | 55 ++- 61 files changed, 981 insertions(+), 544 deletions(-) create mode 100644 packages/framework/src/errors/import.errors.ts create mode 100644 packages/framework/src/resources/workflow/workflow.resource.test-d.ts create mode 100644 packages/framework/src/types/import.types.ts delete mode 100644 packages/framework/src/types/schema.types.ts create mode 100644 packages/framework/src/types/schema.types/base.schema.types.test-d.ts create mode 100644 packages/framework/src/types/schema.types/base.schema.types.ts create mode 100644 packages/framework/src/types/schema.types/index.ts create mode 100644 packages/framework/src/types/schema.types/json.schema.types.test-d.ts create mode 100644 packages/framework/src/types/schema.types/json.schema.types.ts create mode 100644 packages/framework/src/types/schema.types/zod.schema.types.test-d.ts create mode 100644 packages/framework/src/types/schema.types/zod.schema.types.ts create mode 100644 packages/framework/src/utils/import.utils.test.ts create mode 100644 packages/framework/src/utils/import.utils.ts diff --git a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts index f8b650accba..016fed2f67c 100644 --- a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts @@ -5,6 +5,7 @@ import { ChannelStep, DelayOutput, DigestOutput, + JsonSchema, Step, StepOptions, StepOutput, @@ -41,10 +42,10 @@ export class ConstructFrameworkWorkflow { } } - return this.constructFrameworkWorkflow(dbWorkflow, command.action); + return this.constructFrameworkWorkflow(dbWorkflow); } - private constructFrameworkWorkflow(newWorkflow, action) { + private constructFrameworkWorkflow(newWorkflow: NotificationTemplateEntity) { return workflow( newWorkflow.triggers[0].identifier, async ({ step, payload, subscriber }) => { @@ -178,7 +179,8 @@ export class ConstructFrameworkWorkflow { private constructCommonStepOptions(staticStep: NotificationStepEntity): Required { return { - controlSchema: staticStep.template!.controls!.schema, + // TODO: fix the `JSONSchemaDto` type to enforce a non-primitive schema type. + controlSchema: staticStep.template!.controls!.schema as JsonSchema, /* * TODO: add conditions * Used to construct conditions defined with https://react-querybuilder.js.org/ or similar diff --git a/packages/framework/package.json b/packages/framework/package.json index 37845e8fc46..fae96653dcb 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -25,7 +25,7 @@ "next", "nuxt", "remix", - "sveltekit", + "sveltekit", "README.md" ], "scripts": { diff --git a/packages/framework/src/client.ts b/packages/framework/src/client.ts index a4c543a39ba..0847f74d48e 100644 --- a/packages/framework/src/client.ts +++ b/packages/framework/src/client.ts @@ -222,7 +222,7 @@ export class Client { throw new Error(`Invalid component: '${component}'`); } } else { - return result.data; + return result.data as T; } } diff --git a/packages/framework/src/client.validation.test.ts b/packages/framework/src/client.validation.test.ts index b3444d0c7c5..7326ed80de6 100644 --- a/packages/framework/src/client.validation.test.ts +++ b/packages/framework/src/client.validation.test.ts @@ -1,6 +1,4 @@ -/* eslint-disable no-param-reassign */ import { expect, it, describe, beforeEach } from 'vitest'; -import { z } from 'zod'; import { Client } from './client'; import { workflow } from './resources/workflow'; import { ExecutionStateControlsInvalidError } from './errors'; @@ -13,356 +11,77 @@ describe('validation', () => { client = new Client({ secretKey: 'some-secret-key' }); }); - describe('zod', () => { - const zodSchema = z.object({ - foo: z.string(), - baz: z.number(), - }); - - it('should infer types in the step controls', async () => { - workflow('zod-validation', async ({ step }) => { + const jsonSchema = { + type: 'object', + properties: { + foo: { type: 'string' }, + baz: { type: 'number' }, + }, + required: ['foo', 'baz'], + additionalProperties: false, + } as const; + + it('should transform a JSON schema to a valid schema during discovery', async () => { + await client.addWorkflows([ + workflow('json-schema-validation', async ({ step }) => { await step.email( - 'zod-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', - }; - }, + 'json-schema-validation', + async () => ({ + subject: 'Test subject', + body: 'Test body', + }), { - controlSchema: zodSchema, - 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', - }; - }, - }, + controlSchema: jsonSchema, } ); - }); - }); - - it('should infer types in the workflow payload', async () => { - workflow( - 'zod-validation', - async ({ step, payload }) => { - await step.email('zod-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: zodSchema, - } - ); - }); - - it('should infer types in the workflow controls', async () => { - workflow( - 'zod-validation', - async ({ step, controls }) => { - await step.email('zod-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: zodSchema, - } - ); - }); - - it('should transform a zod schema to a json schema during discovery', async () => { - await client.addWorkflows([ - workflow('zod-validation', async ({ step }) => { - await step.email( - 'zod-validation', - async () => ({ - subject: 'Test subject', - body: 'Test body', - }), - { - controlSchema: zodSchema, - } - ); - }), - ]); - - 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 () => { - await client.addWorkflows([ - workflow('zod-validation', async ({ step }) => { - await step.email( - 'test-email', - async () => ({ - subject: 'Test subject', - body: 'Test body', - }), - { - controlSchema: zodSchema, - } - ); - }), - ]); + const discoverResult = client.discover(); + const stepControlSchema = discoverResult.workflows[0].steps[0].controls.schema; - try { - await client.executeWorkflow({ - action: PostActionEnum.EXECUTE, - workflowId: 'zod-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: `zod-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', - }, - ]); - } - }); + expect(stepControlSchema).to.deep.include(jsonSchema); }); - describe('json-schema', () => { - const jsonSchema = { - type: 'object', - properties: { - foo: { type: 'string' }, - baz: { type: 'number' }, - }, - required: ['foo', 'baz'], - additionalProperties: false, - } as const; - - it('should infer types in the step controls', async () => { + it('should throw an error if a property is missing', async () => { + await client.addWorkflows([ workflow('json-schema-validation', async ({ step }) => { await step.email( - 'json-schema-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', - }; - }, + 'test-email', + async () => ({ + subject: 'Test subject', + body: 'Test body', + }), { controlSchema: jsonSchema, - 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( - 'json-schema-validation', - async ({ step, payload }) => { - await step.email('json-schema-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', - }; - }); + }), + ]); + + try { + await client.executeWorkflow({ + action: PostActionEnum.EXECUTE, + workflowId: 'json-schema-validation', + controls: { + foo: '341', }, - { - payloadSchema: jsonSchema, - } + payload: {}, + stepId: 'test-email', + state: [], + subscriber: {}, + }); + } catch (error) { + expect(error).to.be.instanceOf(ExecutionStateControlsInvalidError); + expect((error as ExecutionStateControlsInvalidError).message).to.equal( + 'Workflow with id: `json-schema-validation` has an invalid state. Step with id: `test-email` has invalid `controls`. Please provide the correct step controls.' ); - }); - - it('should infer types in the workflow controls', async () => { - workflow( - 'json-schema-validation', - async ({ step, controls }) => { - await step.email('json-schema-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', - }; - }); - }, + expect((error as ExecutionStateControlsInvalidError).data).to.deep.equal([ { - controlSchema: jsonSchema, - } - ); - }); - - it('should transform a JSON schema to a valid schema during discovery', async () => { - await client.addWorkflows([ - workflow('json-schema-validation', async ({ step }) => { - await step.email( - 'json-schema-validation', - async () => ({ - subject: 'Test subject', - body: 'Test body', - }), - { - controlSchema: jsonSchema, - } - ); - }), - ]); - - 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', - }, + message: "must have required property 'baz'", + path: '', }, - required: ['foo', 'baz'], - type: 'object', - }); - }); - - it('should throw an error if a property is missing', async () => { - await client.addWorkflows([ - workflow('json-schema-validation', async ({ step }) => { - await step.email( - 'test-email', - async () => ({ - subject: 'Test subject', - body: 'Test body', - }), - { - controlSchema: jsonSchema, - } - ); - }), ]); - - try { - await client.executeWorkflow({ - action: PostActionEnum.EXECUTE, - workflowId: 'json-schema-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: `json-schema-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: "must have required property 'baz'", - path: '', - }, - ]); - } - }); + } }); }); diff --git a/packages/framework/src/constants/error.constants.ts b/packages/framework/src/constants/error.constants.ts index 03a6dc826db..7c86a63c4e3 100644 --- a/packages/framework/src/constants/error.constants.ts +++ b/packages/framework/src/constants/error.constants.ts @@ -11,6 +11,7 @@ export enum ErrorCodeEnum { EXECUTION_STATE_RESULT_INVALID_ERROR = 'ExecutionStateResultInvalidError', INVALID_ACTION_ERROR = 'InvalidActionError', METHOD_NOT_ALLOWED_ERROR = 'MethodNotAllowedError', + MISSING_DEPENDENCY_ERROR = 'MissingDependencyError', MISSING_SECRET_KEY_ERROR = 'MissingSecretKeyError', PROVIDER_EXECUTION_FAILED_ERROR = 'ProviderExecutionFailedError', PROVIDER_NOT_FOUND_ERROR = 'ProviderNotFoundError', diff --git a/packages/framework/src/errors/base.errors.ts b/packages/framework/src/errors/base.errors.ts index 9f0cb581833..d4318ded1e2 100644 --- a/packages/framework/src/errors/base.errors.ts +++ b/packages/framework/src/errors/base.errors.ts @@ -67,7 +67,7 @@ export abstract class ServerError extends FrameworkError { stack: cause.stack ?? message, }; } else { - super(`${message}: ${JSON.stringify(cause, null, 2)}`); + super(`${message}${cause ? `: ${JSON.stringify(cause, null, 2)}` : ''}`); this.data = { stack: message, }; diff --git a/packages/framework/src/errors/import.errors.ts b/packages/framework/src/errors/import.errors.ts new file mode 100644 index 00000000000..9b03553bddb --- /dev/null +++ b/packages/framework/src/errors/import.errors.ts @@ -0,0 +1,16 @@ +import { ErrorCodeEnum, HttpStatusEnum } from '../constants'; +import { ServerError } from './base.errors'; + +export class MissingDependencyError extends ServerError { + statusCode = HttpStatusEnum.INTERNAL_SERVER_ERROR; + code = ErrorCodeEnum.MISSING_DEPENDENCY_ERROR; + + constructor(usageReason: string, missingDependencies: string[]) { + const pronoun = missingDependencies.length === 1 ? 'it' : 'them'; + super( + `Tried to use a ${usageReason} in @novu/framework without ${missingDependencies.join( + ', ' + )} installed. Please install ${pronoun} by running \`npm install ${missingDependencies.join(' ')}\`.` + ); + } +} diff --git a/packages/framework/src/resources/workflow/discover-action-step-factory.ts b/packages/framework/src/resources/workflow/discover-action-step-factory.ts index 8b7d7d4fcca..709c0d0107c 100644 --- a/packages/framework/src/resources/workflow/discover-action-step-factory.ts +++ b/packages/framework/src/resources/workflow/discover-action-step-factory.ts @@ -1,6 +1,6 @@ import { ActionStepEnum } from '../../constants'; import { emptySchema } from '../../schemas'; -import type { Awaitable, DiscoverWorkflowOutput, Schema, ActionStep } from '../../types'; +import type { Awaitable, DiscoverWorkflowOutput, Schema, ActionStep, StepOptions, FromSchema } from '../../types'; import { transformSchema } from '../../validators'; import { discoverStep } from './discover-step'; @@ -32,7 +32,7 @@ export async function discoverActionStepFactory( }, resolve: resolve as (controls: Record) => Awaitable>, code: resolve.toString(), - options, + options: options as StepOptions>, providers: [], }); diff --git a/packages/framework/src/resources/workflow/discover-channel-step-factory.ts b/packages/framework/src/resources/workflow/discover-channel-step-factory.ts index 6b3fb0e5656..01920a300cf 100644 --- a/packages/framework/src/resources/workflow/discover-channel-step-factory.ts +++ b/packages/framework/src/resources/workflow/discover-channel-step-factory.ts @@ -1,6 +1,14 @@ import { ChannelStepEnum } from '../../constants'; import { emptySchema } from '../../schemas'; -import type { Awaitable, DiscoverStepOutput, DiscoverWorkflowOutput, Schema, ChannelStep } from '../../types'; +import type { + Awaitable, + DiscoverStepOutput, + DiscoverWorkflowOutput, + Schema, + ChannelStep, + StepOptions, + FromSchema, +} from '../../types'; import { transformSchema } from '../../validators'; import { discoverProviders } from './discover-providers'; import { discoverStep } from './discover-step'; @@ -32,14 +40,14 @@ export async function discoverChannelStepFactory( }, resolve: resolve as (controls: Record) => Awaitable>, code: resolve.toString(), - options, + options: options as StepOptions>, providers: [], }; await discoverStep(targetWorkflow, stepId, step); if (Object.keys(options.providers || {}).length > 0) { - discoverProviders(step, type as ChannelStepEnum, options.providers || {}); + await discoverProviders(step, type as ChannelStepEnum, options.providers || {}); } return { diff --git a/packages/framework/src/resources/workflow/discover-custom-step-factory.ts b/packages/framework/src/resources/workflow/discover-custom-step-factory.ts index 66a6d5ebf36..963d0d37a55 100644 --- a/packages/framework/src/resources/workflow/discover-custom-step-factory.ts +++ b/packages/framework/src/resources/workflow/discover-custom-step-factory.ts @@ -1,5 +1,13 @@ import { emptySchema } from '../../schemas'; -import type { Awaitable, CustomStep, DiscoverWorkflowOutput, StepType, StepOutput } from '../../types'; +import type { + Awaitable, + CustomStep, + DiscoverWorkflowOutput, + StepType, + StepOutput, + StepOptions, + Schema, +} from '../../types'; import { transformSchema } from '../../validators'; import { discoverStep } from './discover-step'; @@ -28,7 +36,7 @@ export async function discoverCustomStepFactory( }, resolve: resolve as (controls: Record) => Awaitable>, code: resolve.toString(), - options, + options: options as StepOptions>, providers: [], }); diff --git a/packages/framework/src/resources/workflow/discover-providers.ts b/packages/framework/src/resources/workflow/discover-providers.ts index 672c0766474..9329a08ce05 100644 --- a/packages/framework/src/resources/workflow/discover-providers.ts +++ b/packages/framework/src/resources/workflow/discover-providers.ts @@ -20,12 +20,13 @@ export async function discoverProviders( ): Promise { const channelSchemas = providerSchemas[channelType]; - Object.entries(providers).forEach(async ([type, resolve]) => { + const providerPromises = Object.entries(providers).map(async ([type, resolve]) => { // eslint-disable-next-line multiline-comment-style // TODO: fix the typing for `type` to use the keyof providerSchema[channelType] // @ts-expect-error - Element implicitly has an 'any' type because expression of type 'string' can't be used to index type const schemas = channelSchemas[type]; - step.providers.push({ + + return { type, code: resolve.toString(), resolve, @@ -33,6 +34,8 @@ export async function discoverProviders( schema: await transformSchema(schemas.output), unknownSchema: schemas.output, }, - }); + }; }); + + step.providers.push(...(await Promise.all(providerPromises))); } diff --git a/packages/framework/src/resources/workflow/workflow.resource.test-d.ts b/packages/framework/src/resources/workflow/workflow.resource.test-d.ts new file mode 100644 index 00000000000..3ad8e6ed0ee --- /dev/null +++ b/packages/framework/src/resources/workflow/workflow.resource.test-d.ts @@ -0,0 +1,241 @@ +import { describe, expectTypeOf } from 'vitest'; +import { workflow } from '.'; +import { Subscriber } from '../../types'; + +describe('workflow function types', () => { + describe('event types', () => { + it('should have the expected subscriber type', () => { + workflow('without-schema', async ({ subscriber }) => { + expectTypeOf(subscriber).toEqualTypeOf(); + }); + }); + + it('should have the expected step functions', () => { + workflow('without-schema', async ({ step }) => { + expectTypeOf(step).toMatchTypeOf<{ + email: unknown; + sms: unknown; + push: unknown; + chat: unknown; + inApp: unknown; + digest: unknown; + delay: unknown; + custom: unknown; + }>(); + }); + }); + }); + + describe('without schema', () => { + it('should infer an unknown record type in the step controls', async () => { + workflow('without-schema', async ({ step }) => { + await step.email( + 'without-schema', + async (controls) => { + expectTypeOf(controls).toEqualTypeOf>(); + + return { + subject: 'Test subject', + body: 'Test body', + }; + }, + { + skip: (controls) => { + expectTypeOf(controls).toEqualTypeOf>(); + + return true; + }, + providers: { + sendgrid: async ({ controls }) => { + expectTypeOf(controls).toEqualTypeOf>(); + + return { + ipPoolName: 'test', + }; + }, + }, + } + ); + }); + }); + + it('should infer an unknown record type in the workflow event payload', async () => { + workflow('without-schema', async ({ step, payload }) => { + await step.email('without-schema', async () => { + expectTypeOf(payload).toEqualTypeOf>(); + + return { + subject: 'Test subject', + body: 'Test body', + }; + }); + }); + }); + + it('should infer an unknown record type in the workflow event controls', async () => { + workflow('without-schema', async ({ step, controls }) => { + await step.email('without-schema', async () => { + expectTypeOf(controls).toEqualTypeOf>(); + + return { + subject: 'Test subject', + body: 'Test body', + }; + }); + }); + }); + + it('should infer an unknown record type in the custom step results', async () => { + workflow('without-schema', async ({ step }) => { + const result = await step.custom('without-schema', async () => { + return { + foo: 'bar', + }; + }); + + expectTypeOf(result).toMatchTypeOf>(); + }); + }); + }); + + describe('json-schema', () => { + const jsonSchema = { + type: 'object', + properties: { + foo: { type: 'string' }, + baz: { type: 'number' }, + }, + required: ['foo'], + additionalProperties: false, + } as const; + + it('should infer an unknown record 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>(); + + return { + subject: 'Test subject', + body: 'Test body', + }; + }, + { + // @ts-expect-error - schema is for a primitive type + controlSchema: primitiveSchema, + } + ); + }); + }); + + it('should infer correct types in the step controls', async () => { + workflow('json-schema', async ({ step }) => { + await step.email( + 'json-schema', + async (controls) => { + expectTypeOf(controls).toEqualTypeOf<{ foo: string; baz?: number }>(); + + return { + subject: 'Test subject', + body: 'Test body', + }; + }, + { + controlSchema: jsonSchema, + skip: (controls) => { + expectTypeOf(controls).toEqualTypeOf<{ foo: string; baz?: number }>(); + + return true; + }, + providers: { + sendgrid: async ({ controls }) => { + expectTypeOf(controls).toEqualTypeOf<{ foo: string; baz?: number }>(); + + return { + ipPoolName: 'test', + }; + }, + }, + } + ); + }); + + it('should infer correct types in the workflow event payload', async () => { + workflow( + 'json-schema-validation', + async ({ step, payload }) => { + await step.email('json-schema-validation', async () => { + expectTypeOf(payload).toEqualTypeOf<{ foo: string; baz?: number }>(); + + return { + subject: 'Test subject', + body: 'Test body', + }; + }); + }, + { + payloadSchema: jsonSchema, + } + ); + }); + + it('should infer correct types in the workflow event controls', async () => { + workflow( + 'json-schema-validation', + async ({ step, controls }) => { + await step.email('json-schema-validation', async () => { + expectTypeOf(controls).toEqualTypeOf<{ foo: string; baz?: number }>(); + + return { + subject: 'Test subject', + body: 'Test body', + }; + }); + }, + { + controlSchema: jsonSchema, + } + ); + }); + + it('should infer correct types in the workflow event controls', async () => { + workflow( + 'json-schema-validation', + async ({ step, controls }) => { + await step.email('json-schema-validation', async () => { + expectTypeOf(controls).toEqualTypeOf<{ foo: string; baz?: number }>(); + + return { + subject: 'Test subject', + body: 'Test body', + }; + }); + }, + { + controlSchema: jsonSchema, + } + ); + }); + + it('should infer the correct types in the custom step results', async () => { + workflow('without-schema', async ({ step }) => { + const result = await step.custom( + 'without-schema', + async () => { + return { + foo: 'bar', + }; + }, + { + outputSchema: jsonSchema, + } + ); + + expectTypeOf(result).toMatchTypeOf<{ foo: string; baz?: number }>(); + }); + }); + }); + }); +}); diff --git a/packages/framework/src/resources/workflow/workflow.resource.ts b/packages/framework/src/resources/workflow/workflow.resource.ts index c6d868d6558..09bdc9e9453 100644 --- a/packages/framework/src/resources/workflow/workflow.resource.ts +++ b/packages/framework/src/resources/workflow/workflow.resource.ts @@ -45,7 +45,7 @@ export function workflow< if (validationResult.success === false) { throw new WorkflowPayloadInvalidError(workflowId, validationResult.errors); } - validatedData = validationResult.data; + validatedData = validationResult.data 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/schemas/providers/chat/index.ts b/packages/framework/src/schemas/providers/chat/index.ts index fbf8095be9b..ded0651ad23 100644 --- a/packages/framework/src/schemas/providers/chat/index.ts +++ b/packages/framework/src/schemas/providers/chat/index.ts @@ -1,5 +1,5 @@ import { ChatProviderIdEnum } from '@novu/shared'; -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; import { genericProviderSchemas } from '../generic.schema'; import { slackProviderSchemas } from './slack.schema'; @@ -14,4 +14,4 @@ export const chatProviderSchemas = { slack: slackProviderSchemas, 'whatsapp-business': genericProviderSchemas, zulip: genericProviderSchemas, -} as const satisfies Record; +} as const satisfies Record; diff --git a/packages/framework/src/schemas/providers/chat/slack.schema.ts b/packages/framework/src/schemas/providers/chat/slack.schema.ts index 7b05fa4e73b..a49f4746ebe 100644 --- a/packages/framework/src/schemas/providers/chat/slack.schema.ts +++ b/packages/framework/src/schemas/providers/chat/slack.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; /** * Slack message payload schema @@ -41,7 +41,7 @@ const slackOutputSchema = { }, }, additionalProperties: true, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const slackProviderSchemas = { output: slackOutputSchema, diff --git a/packages/framework/src/schemas/providers/email/index.ts b/packages/framework/src/schemas/providers/email/index.ts index 8609f6d2524..c831020b4ac 100644 --- a/packages/framework/src/schemas/providers/email/index.ts +++ b/packages/framework/src/schemas/providers/email/index.ts @@ -1,5 +1,5 @@ import { EmailProviderIdEnum } from '@novu/shared'; -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; import { genericProviderSchemas } from '../generic.schema'; import { mailgunProviderSchemas } from './mailgun.schema'; import { mailjetProviderSchemas } from './mailjet.schema'; @@ -29,4 +29,4 @@ export const emailProviderSchemas = { sendinblue: genericProviderSchemas, ses: genericProviderSchemas, sparkpost: genericProviderSchemas, -} as const satisfies Record; +} as const satisfies Record; diff --git a/packages/framework/src/schemas/providers/email/mailgun.schema.ts b/packages/framework/src/schemas/providers/email/mailgun.schema.ts index f2d9c9d2004..031d41b22b7 100644 --- a/packages/framework/src/schemas/providers/email/mailgun.schema.ts +++ b/packages/framework/src/schemas/providers/email/mailgun.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; /** * Mailgun `POST /messages` schema @@ -73,7 +73,7 @@ const mailgunOutputSchema = { }, required: [], additionalProperties: true, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const mailgunProviderSchemas = { output: mailgunOutputSchema, diff --git a/packages/framework/src/schemas/providers/email/mailjet.schema.ts b/packages/framework/src/schemas/providers/email/mailjet.schema.ts index c2af1df9acb..b21384247cc 100644 --- a/packages/framework/src/schemas/providers/email/mailjet.schema.ts +++ b/packages/framework/src/schemas/providers/email/mailjet.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; const address = { type: 'object', @@ -9,7 +9,7 @@ const address = { description: `JSON object, containing 2 properties: Name and Email address of a previously validated and active sender. Including the Name property in the JSON is optional. This property is not mandatory in case you use TemplateID and you specified a From address for the template. Format : { "Email":"value", "Name":"value" }.`, required: ['Email'], additionalProperties: true, -} satisfies Schema; +} satisfies JsonSchema; const attachment = { type: 'object', @@ -20,7 +20,7 @@ const attachment = { }, required: ['ContentType', 'Filename', 'Base64Content'], additionalProperties: true, -} satisfies Schema; +} satisfies JsonSchema; const inlineAttatchment = { type: 'object', @@ -32,7 +32,7 @@ const inlineAttatchment = { }, required: ['ContentType', 'Filename', 'Base64Content'], additionalProperties: true, -} satisfies Schema; +} satisfies JsonSchema; /** * Mailjet `POST /send` schema @@ -100,7 +100,7 @@ const mailjetOutputSchema = { }, required: [], additionalProperties: true, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const mailjetProviderSchemas = { output: mailjetOutputSchema, diff --git a/packages/framework/src/schemas/providers/email/nodemailer.schema.ts b/packages/framework/src/schemas/providers/email/nodemailer.schema.ts index 27bd989e1aa..0d960051525 100644 --- a/packages/framework/src/schemas/providers/email/nodemailer.schema.ts +++ b/packages/framework/src/schemas/providers/email/nodemailer.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; const address = { type: 'object', @@ -7,7 +7,7 @@ const address = { name: { type: 'string' }, }, additionalProperties: true, -} as const satisfies Schema; +} as const satisfies JsonSchema; const attachmentLike = { type: 'object', @@ -16,7 +16,7 @@ const attachmentLike = { path: { type: 'string' }, }, additionalProperties: true, -} as const satisfies Schema; +} as const satisfies JsonSchema; /** * Nodemailer `sendMail` schema @@ -107,7 +107,7 @@ const nodemailerOutputSchema = { }, required: [], additionalProperties: true, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const nodemailerProviderSchemas = { output: nodemailerOutputSchema, diff --git a/packages/framework/src/schemas/providers/email/novu-email.schema.ts b/packages/framework/src/schemas/providers/email/novu-email.schema.ts index 970fb4e93d8..6d05597b297 100644 --- a/packages/framework/src/schemas/providers/email/novu-email.schema.ts +++ b/packages/framework/src/schemas/providers/email/novu-email.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; /** * Novu email schema @@ -8,7 +8,7 @@ const novuEmailOutputSchema = { properties: {}, required: [], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const novuEmailProviderSchemas = { output: novuEmailOutputSchema, diff --git a/packages/framework/src/schemas/providers/email/sendgrid.schema.ts b/packages/framework/src/schemas/providers/email/sendgrid.schema.ts index 0e72a6c37ac..689275b97d4 100644 --- a/packages/framework/src/schemas/providers/email/sendgrid.schema.ts +++ b/packages/framework/src/schemas/providers/email/sendgrid.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; /** * Sendgrid `POST /v3/mail/send` schema @@ -490,7 +490,7 @@ const sendgridOutputSchema = { }, required: [], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const sendgridProviderSchemas = { output: sendgridOutputSchema, diff --git a/packages/framework/src/schemas/providers/generic.schema.ts b/packages/framework/src/schemas/providers/generic.schema.ts index ca720c3d76a..ad27fe3c456 100644 --- a/packages/framework/src/schemas/providers/generic.schema.ts +++ b/packages/framework/src/schemas/providers/generic.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../types/schema.types'; +import type { JsonSchema } from '../../types/schema.types'; /** * A permissive schema for untyped providers to use. @@ -15,4 +15,4 @@ export const genericProviderSchemas = { required: [], additionalProperties: true, } as const, -} satisfies { output: Schema }; +} satisfies { output: JsonSchema }; diff --git a/packages/framework/src/schemas/providers/inApp/index.ts b/packages/framework/src/schemas/providers/inApp/index.ts index 8057477cf16..d88a4df1331 100644 --- a/packages/framework/src/schemas/providers/inApp/index.ts +++ b/packages/framework/src/schemas/providers/inApp/index.ts @@ -1,7 +1,7 @@ import { InAppProviderIdEnum } from '@novu/shared'; -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; import { novuInAppProviderSchemas } from './novu-inapp.schema'; export const inAppProviderSchemas = { novu: novuInAppProviderSchemas, -} as const satisfies Record; +} as const satisfies Record; diff --git a/packages/framework/src/schemas/providers/inApp/novu-inapp.schema.ts b/packages/framework/src/schemas/providers/inApp/novu-inapp.schema.ts index 15bebcfcab5..29a0111278f 100644 --- a/packages/framework/src/schemas/providers/inApp/novu-inapp.schema.ts +++ b/packages/framework/src/schemas/providers/inApp/novu-inapp.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; /** * Novu in-app schema @@ -8,7 +8,7 @@ const novuInAppOutputSchema = { properties: {}, required: [], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const novuInAppProviderSchemas = { output: novuInAppOutputSchema, diff --git a/packages/framework/src/schemas/providers/index.ts b/packages/framework/src/schemas/providers/index.ts index 4970c3a1556..3dbf7648ba0 100644 --- a/packages/framework/src/schemas/providers/index.ts +++ b/packages/framework/src/schemas/providers/index.ts @@ -1,5 +1,5 @@ import { ChannelStepEnum } from '../../constants'; -import { Schema } from '../../types/schema.types'; +import type { JsonSchema } from '../../types/schema.types'; import { chatProviderSchemas } from './chat'; import { emailProviderSchemas } from './email'; import { inAppProviderSchemas } from './inApp'; @@ -12,4 +12,4 @@ export const providerSchemas = { email: emailProviderSchemas, push: pushProviderSchemas, in_app: inAppProviderSchemas, -} as const satisfies Record>; +} as const satisfies Record>; diff --git a/packages/framework/src/schemas/providers/push/apns.schema.ts b/packages/framework/src/schemas/providers/push/apns.schema.ts index 77c8a7e8dad..cc5ae5953bc 100644 --- a/packages/framework/src/schemas/providers/push/apns.schema.ts +++ b/packages/framework/src/schemas/providers/push/apns.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; const sound = { anyOf: [ @@ -10,7 +10,7 @@ const sound = { required: ['name', 'volume', 'critical'], }, ], -} satisfies Schema; +} satisfies JsonSchema; /** * APNS `POST /3/device/{device_token}` schema @@ -100,7 +100,7 @@ The value of this header must accurately reflect the contents of your notificati }, required: [], additionalProperties: true, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const apnsProviderSchemas = { output: apnsOutputSchema, diff --git a/packages/framework/src/schemas/providers/push/expo.schema.ts b/packages/framework/src/schemas/providers/push/expo.schema.ts index d317b6e00f9..b839ce639a4 100644 --- a/packages/framework/src/schemas/providers/push/expo.schema.ts +++ b/packages/framework/src/schemas/providers/push/expo.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; /** * Expo `POST /v2/push/send` schema @@ -71,7 +71,7 @@ const expoOutputSchema = { }, required: [], additionalProperties: true, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const expoProviderSchemas = { output: expoOutputSchema, diff --git a/packages/framework/src/schemas/providers/push/fcm.schema.ts b/packages/framework/src/schemas/providers/push/fcm.schema.ts index c5f8ed365c0..6b9b00897d5 100644 --- a/packages/framework/src/schemas/providers/push/fcm.schema.ts +++ b/packages/framework/src/schemas/providers/push/fcm.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; /** * FCM `send` schema @@ -147,7 +147,7 @@ const fcmOutputSchema = { }, required: [], additionalProperties: true, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const fcmProviderSchemas = { output: fcmOutputSchema, diff --git a/packages/framework/src/schemas/providers/push/index.ts b/packages/framework/src/schemas/providers/push/index.ts index f84ae27f092..7a7a66cc6f6 100644 --- a/packages/framework/src/schemas/providers/push/index.ts +++ b/packages/framework/src/schemas/providers/push/index.ts @@ -1,5 +1,5 @@ import { PushProviderIdEnum } from '@novu/shared'; -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; import { genericProviderSchemas } from '../generic.schema'; import { apnsProviderSchemas } from './apns.schema'; import { expoProviderSchemas } from './expo.schema'; @@ -14,4 +14,4 @@ export const pushProviderSchemas = { 'pusher-beams': genericProviderSchemas, pushpad: genericProviderSchemas, 'push-webhook': genericProviderSchemas, -} as const satisfies Record; +} as const satisfies Record; diff --git a/packages/framework/src/schemas/providers/push/one-signal.schema.ts b/packages/framework/src/schemas/providers/push/one-signal.schema.ts index 5945bae0e72..d381541ee85 100644 --- a/packages/framework/src/schemas/providers/push/one-signal.schema.ts +++ b/packages/framework/src/schemas/providers/push/one-signal.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; /** * OneSignal `POST /notifications` schema @@ -1383,7 +1383,7 @@ const oneSignalOutputSchema = { ], required: [], additionalProperties: true, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const oneSignalProviderSchema = { output: oneSignalOutputSchema, diff --git a/packages/framework/src/schemas/providers/sms/index.ts b/packages/framework/src/schemas/providers/sms/index.ts index f809caea228..c782d592f21 100644 --- a/packages/framework/src/schemas/providers/sms/index.ts +++ b/packages/framework/src/schemas/providers/sms/index.ts @@ -1,5 +1,5 @@ import { SmsProviderIdEnum } from '@novu/shared'; -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; import { genericProviderSchemas } from '../generic.schema'; import { novuSmsProviderSchemas } from './novu-sms.schema'; import { twilioProviderSchemas } from './twilio.schema'; @@ -36,4 +36,4 @@ export const smsProviderSchemas = { telnyx: genericProviderSchemas, termii: genericProviderSchemas, twilio: twilioProviderSchemas, -} as const satisfies Record; +} as const satisfies Record; diff --git a/packages/framework/src/schemas/providers/sms/novu-sms.schema.ts b/packages/framework/src/schemas/providers/sms/novu-sms.schema.ts index d82b6a77104..7b3ab7a6f4d 100644 --- a/packages/framework/src/schemas/providers/sms/novu-sms.schema.ts +++ b/packages/framework/src/schemas/providers/sms/novu-sms.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; /** * Novu sms schema @@ -8,7 +8,7 @@ const novuSmsOutputSchema = { properties: {}, required: [], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const novuSmsProviderSchemas = { output: novuSmsOutputSchema, diff --git a/packages/framework/src/schemas/providers/sms/twilio.schema.ts b/packages/framework/src/schemas/providers/sms/twilio.schema.ts index 4208ec5ebeb..86b615e8b61 100644 --- a/packages/framework/src/schemas/providers/sms/twilio.schema.ts +++ b/packages/framework/src/schemas/providers/sms/twilio.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; /** * Twilio `POST /2010-04-01/Accounts/{AccountSid}/Messages.json` schema @@ -145,7 +145,7 @@ const twilioOutputSchema = { }, required: [], additionalProperties: true, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const twilioProviderSchemas = { output: twilioOutputSchema, diff --git a/packages/framework/src/schemas/steps/actions/delay.schema.ts b/packages/framework/src/schemas/steps/actions/delay.schema.ts index c0ab609dd96..c3144ddabdb 100644 --- a/packages/framework/src/schemas/steps/actions/delay.schema.ts +++ b/packages/framework/src/schemas/steps/actions/delay.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; export const delayOutputSchema = { type: 'object', @@ -15,7 +15,7 @@ export const delayOutputSchema = { }, required: ['amount', 'unit'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const delayResultSchema = { type: 'object', @@ -24,7 +24,7 @@ export const delayResultSchema = { }, required: ['duration'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const delayActionSchemas = { output: delayOutputSchema, diff --git a/packages/framework/src/schemas/steps/actions/digest.schema.ts b/packages/framework/src/schemas/steps/actions/digest.schema.ts index f69819fc94b..445e76be4d1 100644 --- a/packages/framework/src/schemas/steps/actions/digest.schema.ts +++ b/packages/framework/src/schemas/steps/actions/digest.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; export const digestRegularOutputSchema = { type: 'object', @@ -26,7 +26,7 @@ export const digestRegularOutputSchema = { }, required: ['amount', 'unit'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const digestTimedOutputSchema = { type: 'object', @@ -38,11 +38,11 @@ export const digestTimedOutputSchema = { }, required: ['cron'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const digestOutputSchema = { oneOf: [digestRegularOutputSchema, digestTimedOutputSchema], -} as const satisfies Schema; +} as const satisfies JsonSchema; export const digestResultSchema = { type: 'object', @@ -63,7 +63,7 @@ export const digestResultSchema = { }, required: ['events'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const digestActionSchemas = { output: digestOutputSchema, diff --git a/packages/framework/src/schemas/steps/actions/index.ts b/packages/framework/src/schemas/steps/actions/index.ts index f3201bde2ea..96ca27a5a11 100644 --- a/packages/framework/src/schemas/steps/actions/index.ts +++ b/packages/framework/src/schemas/steps/actions/index.ts @@ -1,5 +1,5 @@ import { ActionStepEnum } from '../../../constants'; -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; import { delayActionSchemas } from './delay.schema'; import { digestActionSchemas } from './digest.schema'; @@ -8,4 +8,4 @@ type RegularActionStepSchema = Exclude; export const actionStepSchemas = { delay: delayActionSchemas, digest: digestActionSchemas, -} satisfies Record; +} satisfies Record; diff --git a/packages/framework/src/schemas/steps/channels/chat.schema.ts b/packages/framework/src/schemas/steps/channels/chat.schema.ts index 57b904f92d8..23d647c8af2 100644 --- a/packages/framework/src/schemas/steps/channels/chat.schema.ts +++ b/packages/framework/src/schemas/steps/channels/chat.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; const chatOutputSchema = { type: 'object', @@ -7,14 +7,14 @@ const chatOutputSchema = { }, required: ['body'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; const chatResultSchema = { type: 'object', properties: {}, required: [], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const chatChannelSchemas = { output: chatOutputSchema, diff --git a/packages/framework/src/schemas/steps/channels/email.schema.ts b/packages/framework/src/schemas/steps/channels/email.schema.ts index f9f445b8e08..d1fec2288db 100644 --- a/packages/framework/src/schemas/steps/channels/email.schema.ts +++ b/packages/framework/src/schemas/steps/channels/email.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; const emailOutputSchema = { type: 'object', @@ -8,14 +8,14 @@ const emailOutputSchema = { }, required: ['subject', 'body'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; const emailResultSchema = { type: 'object', properties: {}, required: [], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const emailChannelSchemas = { output: emailOutputSchema, diff --git a/packages/framework/src/schemas/steps/channels/in-app.schema.ts b/packages/framework/src/schemas/steps/channels/in-app.schema.ts index e790782b3be..13e3d5cf23d 100644 --- a/packages/framework/src/schemas/steps/channels/in-app.schema.ts +++ b/packages/framework/src/schemas/steps/channels/in-app.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; const ABSOLUTE_AND_RELATIVE_URL_REGEX = '^(?!mailto:)(?:(https?):\\/\\/[^\\s/$.?#].[^\\s]*)|^(\\/[^\\s]*)$'; @@ -39,7 +39,7 @@ const redirectSchema = { }, required: ['url'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; const actionSchema = { type: 'object', @@ -49,7 +49,7 @@ const actionSchema = { }, required: ['label'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; const inAppOutputSchema = { type: 'object', @@ -64,7 +64,7 @@ const inAppOutputSchema = { }, required: ['body'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; const inAppResultSchema = { type: 'object', @@ -76,7 +76,7 @@ const inAppResultSchema = { }, required: ['seen', 'read', 'lastSeenDate', 'lastReadDate'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const inAppChannelSchemas = { output: inAppOutputSchema, diff --git a/packages/framework/src/schemas/steps/channels/index.ts b/packages/framework/src/schemas/steps/channels/index.ts index 82436baa7e4..1537b2845af 100644 --- a/packages/framework/src/schemas/steps/channels/index.ts +++ b/packages/framework/src/schemas/steps/channels/index.ts @@ -1,5 +1,5 @@ import { ChannelStepEnum } from '../../../constants'; -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; import { chatChannelSchemas } from './chat.schema'; import { emailChannelSchemas } from './email.schema'; import { inAppChannelSchemas } from './in-app.schema'; @@ -12,4 +12,4 @@ export const channelStepSchemas = { push: pushChannelSchemas, email: emailChannelSchemas, in_app: inAppChannelSchemas, -} as const satisfies Record; +} as const satisfies Record; diff --git a/packages/framework/src/schemas/steps/channels/push.schema.ts b/packages/framework/src/schemas/steps/channels/push.schema.ts index 5c8a3cf5ccb..670924ffc50 100644 --- a/packages/framework/src/schemas/steps/channels/push.schema.ts +++ b/packages/framework/src/schemas/steps/channels/push.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import type { JsonSchema } from '../../../types/schema.types'; const pushOutputSchema = { type: 'object', @@ -8,14 +8,14 @@ const pushOutputSchema = { }, required: ['subject', 'body'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; const pushResultSchema = { type: 'object', properties: {}, required: [], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const pushChannelSchemas = { output: pushOutputSchema, diff --git a/packages/framework/src/schemas/steps/channels/sms.schema.ts b/packages/framework/src/schemas/steps/channels/sms.schema.ts index 3083189df0a..13e5350de8c 100644 --- a/packages/framework/src/schemas/steps/channels/sms.schema.ts +++ b/packages/framework/src/schemas/steps/channels/sms.schema.ts @@ -1,4 +1,4 @@ -import { Schema } from '../../../types/schema.types'; +import { JsonSchema } from '../../../types/schema.types'; const smsOutputSchema = { type: 'object', @@ -7,14 +7,14 @@ const smsOutputSchema = { }, required: ['body'], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; const smsResultSchema = { type: 'object', properties: {}, required: [], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; export const smsChannelSchemas = { output: smsOutputSchema, diff --git a/packages/framework/src/schemas/steps/empty.schema.ts b/packages/framework/src/schemas/steps/empty.schema.ts index 324ebb09f2b..3b9af5dbefd 100644 --- a/packages/framework/src/schemas/steps/empty.schema.ts +++ b/packages/framework/src/schemas/steps/empty.schema.ts @@ -1,8 +1,8 @@ -import { Schema } from '../../types/schema.types'; +import type { JsonSchema } from '../../types/schema.types'; export const emptySchema = { type: 'object', properties: {}, required: [], additionalProperties: false, -} as const satisfies Schema; +} as const satisfies JsonSchema; diff --git a/packages/framework/src/schemas/steps/trigger.schema.ts b/packages/framework/src/schemas/steps/trigger.schema.ts index 393583fad4b..426fa16f54f 100644 --- a/packages/framework/src/schemas/steps/trigger.schema.ts +++ b/packages/framework/src/schemas/steps/trigger.schema.ts @@ -1,6 +1,6 @@ -import { Schema } from '../../types/schema.types'; +import type { JsonSchema } from '../../types/schema.types'; -export const triggerSchema: Schema = { +export const triggerSchema = { type: 'object', properties: { to: { type: 'string', pattern: '/[0-9a-f]+/' }, @@ -8,4 +8,4 @@ export const triggerSchema: Schema = { }, required: ['to', 'body'], additionalProperties: false, -}; +} as const satisfies JsonSchema; diff --git a/packages/framework/src/types/import.types.ts b/packages/framework/src/types/import.types.ts new file mode 100644 index 00000000000..04aa54abb21 --- /dev/null +++ b/packages/framework/src/types/import.types.ts @@ -0,0 +1,39 @@ +export type ImportRequirement = { + /** + * The name of the dependency. + * + * This is a necessary duplicate as ESM does not provide a consistent API for + * reading the name of a dependency that can't be resolved. + * + * @example + * ```typescript + * 'module-name' + * ``` + */ + name: string; + /** + * The import statement for the required dependency. An explicit `import('module-name')` + * call with a static module string is necessary to ensure that the bundler will make + * the dependency available for usage after tree-shaking. Without a static string, + * tree-shaking may aggressively remove the import, making it unavailable. + * + * This syntax is required during synchronous declaration (e.g. on a class property), + * but should only be awaited when you can handle a runtime import error. + * + * @example + * ```typescript + * import('module-name') + * ``` + */ + import: Promise<{ default: unknown } & Record>; + /** + * The required exports of the dependency. The availability of these exports are + * checked by the import validator to verify the dependency is installed. + * + * @example + * ```typescript + * ['my-export'] + * ``` + */ + exports: readonly string[]; +}; diff --git a/packages/framework/src/types/schema.types.ts b/packages/framework/src/types/schema.types.ts deleted file mode 100644 index b7884be33d4..00000000000 --- a/packages/framework/src/types/schema.types.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { JSONSchema, FromSchema as JsonSchemaInfer } from 'json-schema-to-ts'; -import zod from 'zod'; - -export type JsonSchema = Exclude; - -/** - * A schema used to validate a JSON object. - * - * Supported schemas: - * - JSONSchema - * - ZodSchema - */ -export type Schema = JsonSchema | zod.ZodSchema; - -/** - * Infer the type of a Schema for unvalidated data. - * - * The resulting type has default properties set to optional, - * reflecting the fact that the data is unvalidated and has - * not had default properties set. - * - * @example - * ```ts - * type MySchema = FromSchemaUnvalidated; - * ``` - */ -export type FromSchemaUnvalidated = - /* - * Handle each Schema's type inference individually until - * all Schema types are exhausted. - */ - - // JSONSchema - T extends JSONSchema - ? JsonSchemaInfer - : // ZodSchema - T extends zod.ZodSchema - ? zod.input - : // All schema types exhausted. - never; - -/** - * Infer the type of a Schema for validated data. - * - * The resulting type has default properties set to required, - * reflecting the fact that the data has been validated and - * default properties have been set. - * - * @example - * ```ts - * type MySchema = FromSchema; - * ``` - */ -export type FromSchema = - /* - * Handle each Schema's type inference individually until - * all Schema types are exhausted. - */ - - // JSONSchema - T extends JSONSchema - ? JsonSchemaInfer - : // ZodSchema - T extends zod.ZodSchema - ? zod.infer - : // All schema types exhausted. - never; 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 new file mode 100644 index 00000000000..9110c2d47d4 --- /dev/null +++ b/packages/framework/src/types/schema.types/base.schema.types.test-d.ts @@ -0,0 +1,77 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { z } from 'zod'; +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>(); + }); + + 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 = FromSchema; + + expectTypeOf().toEqualTypeOf(); + }); + + it('should infer a Json Schema type', () => { + const testJsonSchema = { + type: 'object', + properties: { + foo: { type: 'string', default: 'bar' }, + bar: { type: 'string' }, + }, + additionalProperties: false, + } as const; + + expectTypeOf>().toEqualTypeOf<{ foo: string; bar?: string }>(); + }); + + it('should infer a Zod Schema type', () => { + const testZodSchema = z.object({ + foo: z.string().default('bar'), + bar: z.string().optional(), + }); + + expectTypeOf>().toEqualTypeOf<{ foo: string; bar?: string }>(); + }); +}); + +describe('FromSchemaUnvalidated', () => { + it('should infer an unknown record type when a generic schema is provided', () => { + expectTypeOf>().toEqualTypeOf>(); + }); + + 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(); + }); + + it('should infer a Json Schema type', () => { + const testJsonSchema = { + type: 'object', + properties: { + foo: { type: 'string', default: 'bar' }, + bar: { type: 'string' }, + }, + additionalProperties: false, + } as const; + + expectTypeOf>().toEqualTypeOf<{ foo?: string; bar?: string }>(); + }); + + it('should infer a Zod Schema type', () => { + const testZodSchema = z.object({ + foo: z.string().default('bar'), + bar: z.string().optional(), + }); + + 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 new file mode 100644 index 00000000000..630ba37e9e5 --- /dev/null +++ b/packages/framework/src/types/schema.types/base.schema.types.ts @@ -0,0 +1,45 @@ +import type { InferJsonSchema, JsonSchemaMinimal } from './json.schema.types'; +import type { InferZodSchema, ZodSchemaMinimal } from './zod.schema.types'; + +/** + * A schema used to validate a JSON object. + */ +export type Schema = JsonSchemaMinimal | ZodSchemaMinimal; + +/** + * Main utility type for schema inference + * + * @param T - The Schema to infer the 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. + */ +type InferSchema = + | InferJsonSchema + | InferZodSchema; + +/** + * Infer the type of a Schema for unvalidated data. + * + * The resulting type has default properties set to optional, + * reflecting the fact that the data is unvalidated and has + * not had default properties set. + * + * @example + * ```ts + * type MySchema = FromSchemaUnvalidated; + * ``` + */ +export type FromSchemaUnvalidated = InferSchema; + +/** + * Infer the type of a Schema for validated data. + * + * The resulting type has default properties set to required, + * reflecting the fact that the data has been validated and + * default properties have been set. + * + * @example + * ```ts + * type MySchema = FromSchema; + * ``` + */ +export type FromSchema = InferSchema; diff --git a/packages/framework/src/types/schema.types/index.ts b/packages/framework/src/types/schema.types/index.ts new file mode 100644 index 00000000000..b312dd77e4d --- /dev/null +++ b/packages/framework/src/types/schema.types/index.ts @@ -0,0 +1,3 @@ +export type { JsonSchema } from './json.schema.types'; +export type { ZodSchemaMinimal, ZodSchema } from './zod.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 new file mode 100644 index 00000000000..4a786aad3a7 --- /dev/null +++ b/packages/framework/src/types/schema.types/json.schema.types.test-d.ts @@ -0,0 +1,52 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { InferJsonSchema, JsonSchema } from './json.schema.types'; + +describe('JsonSchema types', () => { + const testSchema = { + type: 'object', + properties: { + foo: { type: 'string', default: 'bar' }, + bar: { type: 'string' }, + }, + additionalProperties: false, + } as const satisfies JsonSchema; + + describe('validated data', () => { + it('should compile when the expected properties are provided', () => { + expectTypeOf>().toEqualTypeOf<{ + foo: string; + bar?: string; + }>(); + }); + + it('should not compile when the schema is not a JsonSchema', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('should not compile when the schema is generic', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('should not compile when the schema is a primitive JsonSchema', () => { + const testPrimitiveSchema = { type: 'string' } as const; + + expectTypeOf>().toEqualTypeOf(); + }); + + 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>().toEqualTypeOf<{ + foo: number; + }>(); + }); + }); + + describe('unvalidated data', () => { + it('should keep the defaulted properties optional', () => { + 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 new file mode 100644 index 00000000000..71d378beefb --- /dev/null +++ b/packages/framework/src/types/schema.types/json.schema.types.ts @@ -0,0 +1,49 @@ +import type { JSONSchema, FromSchema as JsonSchemaInfer } from 'json-schema-to-ts'; + +/** + * A minimal JSON schema type. + * + * 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[] }; + +/** + * A JSON schema + */ +export type JsonSchema = Exclude & JsonSchemaMinimal; + +/** + * Infer the data type of a JsonSchema. + * + * @param T - The `JsonSchema` 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 + * const mySchema = { + * type: 'object', + * properties: { + * name: { type: 'string' }, + * email: { type: 'string' }, + * }, + * required: ['name'], + * additionalProperties: false, + * } as const satisfies JsonSchema; + * + * // has type { name: string, email?: string } + * type MySchema = InferJsonSchema; + * ``` + */ +export type InferJsonSchema = + // Firstly, narrow to the minimal schema type without using the `json-schema-to-ts` import + T extends JsonSchemaMinimal + ? // 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 + : 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 new file mode 100644 index 00000000000..a85b603f5a7 --- /dev/null +++ b/packages/framework/src/types/schema.types/zod.schema.types.test-d.ts @@ -0,0 +1,49 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { z } from 'zod'; +import { InferZodSchema, ZodSchemaMinimal } from './zod.schema.types'; + +describe('ZodSchema', () => { + 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<{ + foo: string; + bar?: string; + }>(); + }); + + it('should not compile when the schema is not a ZodSchema', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('should not compile when the schema is generic', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('should not compile when the schema is a primitive ZodSchema', () => { + const testPrimitiveSchema = z.string(); + + expectTypeOf>().toEqualTypeOf(); + }); + + 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>().toEqualTypeOf<{ + foo: number; + }>(); + }); + }); + + describe('unvalidated data', () => { + it('should keep the defaulted properties optional', () => { + 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 new file mode 100644 index 00000000000..efbb6d35604 --- /dev/null +++ b/packages/framework/src/types/schema.types/zod.schema.types.ts @@ -0,0 +1,45 @@ +import type zod from 'zod'; + +/** + * A ZodSchema used to validate a JSON object. + */ +export type ZodSchema = zod.ZodType, zod.ZodTypeDef, Record>; + +/** + * A minimal ZodSchema type. + * + * It is necessary to define a minimal ZodSchema type to enable correct inference + * when Zod is not available, as Typescript doesn't support detection of module + * availability via `typeof import('zod')`. + */ +export type ZodSchemaMinimal = { + readonly safeParseAsync: unknown; +}; + +/** + * Infer the data type of a ZodSchema. + * + * @param T - The ZodSchema 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. + * + * @example + * ```ts + * const mySchema = z.object({ + * name: z.string(), + * email: z.string().optional(), + * }); + * + * // has type { name: string, email?: string } + * type MySchema = InferZodSchema; + * ``` + */ +export type InferZodSchema = + // Firstly, narrow to the minimal schema type without using the `zod` import + T extends ZodSchemaMinimal + ? // Secondly, narrow to the Zod type to provide type-safety to `zod.infer` and `zod.input` + T extends ZodSchema + ? Options['validated'] extends true + ? zod.infer + : zod.input + : never + : never; diff --git a/packages/framework/src/types/util.types.ts b/packages/framework/src/types/util.types.ts index 0246e75632b..0e5fc0a8f9a 100644 --- a/packages/framework/src/types/util.types.ts +++ b/packages/framework/src/types/util.types.ts @@ -1,3 +1,8 @@ +/* + * THIS FILE SHOULD NOT DEPEND ON ANY OTHER FILES. + * IT SHOULD ONLY CONTAIN UTILITY TYPES. + */ + /** * A type that represents either `A` or `B`. Shared properties retain their * types and unique properties are marked as optional. diff --git a/packages/framework/src/types/validator.types.ts b/packages/framework/src/types/validator.types.ts index 36826f8bf2b..933c39f8284 100644 --- a/packages/framework/src/types/validator.types.ts +++ b/packages/framework/src/types/validator.types.ts @@ -1,6 +1,8 @@ import type { ValidateFunction as AjvValidateFunction } from 'ajv'; import type { ParseReturnType } from 'zod'; -import type { Schema, JsonSchema, FromSchema, FromSchemaUnvalidated } from './schema.types'; +import type { Schema, FromSchema, FromSchemaUnvalidated } from './schema.types'; +import type { JsonSchema } from './schema.types/json.schema.types'; +import type { ImportRequirement } from './import.types'; export type ValidateFunction = AjvValidateFunction | ((data: T) => ParseReturnType); @@ -19,8 +21,7 @@ export type ValidateResult = data: T; }; -// eslint-disable-next-line @typescript-eslint/naming-convention -export interface Validator { +export type Validator = { validate: < T_Unvalidated extends Record = FromSchemaUnvalidated, T_Validated extends Record = FromSchema, @@ -28,6 +29,7 @@ export interface Validator { data: T_Unvalidated, schema: T_Schema ) => Promise>; - canHandle: (schema: Schema) => schema is T_Schema; + canHandle: (schema: Schema) => Promise; transformToJsonSchema: (schema: T_Schema) => Promise; -} + requiredImports: readonly ImportRequirement[]; +}; diff --git a/packages/framework/src/utils/import.utils.test.ts b/packages/framework/src/utils/import.utils.test.ts new file mode 100644 index 00000000000..ea08ba73fd2 --- /dev/null +++ b/packages/framework/src/utils/import.utils.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { checkDependencies } from './import.utils'; + +describe('import utils', () => { + describe('checkDependencies', () => { + it('should not throw an error if all dependencies are installed', async () => { + await expect( + checkDependencies( + [{ name: 'typescript', import: import('typescript'), exports: ['tokenToString'] }], + 'test schema' + ) + ).resolves.not.toThrow(); + }); + + it('should throw an error if a single dependency is not installed', async () => { + await expect( + checkDependencies( + // @ts-expect-error - Cannot find module 'missing-random-dependency' or its corresponding type declarations. + [{ name: 'missing-random-dependency', import: import('missing-random-dependency'), exports: [] }], + 'test schema' + ) + ).rejects.toThrow( + 'Tried to use a test schema in @novu/framework without missing-random-dependency installed. Please install it by running `npm install missing-random-dependency`.' + ); + }); + + it('should throw an error if multiple dependencies are not installed', async () => { + await expect( + checkDependencies( + [ + // @ts-expect-error - Cannot find module 'missing-random-dependency-1' or its corresponding type declarations. + { name: 'missing-random-dependency-1', import: import('missing-random-dependency-1'), exports: [] }, + // @ts-expect-error - Cannot find module 'missing-random-dependency-2' or its corresponding type declarations. + { name: 'missing-random-dependency-2', import: import('missing-random-dependency-2'), exports: [] }, + ], + 'test schema' + ) + ).rejects.toThrow( + 'Tried to use a test schema in @novu/framework without missing-random-dependency-1, missing-random-dependency-2 installed. Please install them by running `npm install missing-random-dependency-1 missing-random-dependency-2`.' + ); + }); + + it('should throw an error listing a single dependency that is not installed when using a root and non-root import', async () => { + await expect( + checkDependencies( + [ + // @ts-expect-error - Cannot find module 'missing-random-dependency' or its corresponding type declarations. + { name: 'missing-random-dependency', import: import('missing-random-dependency'), exports: [] }, + // @ts-expect-error - Cannot find module 'missing-random-dependency/nested' or its corresponding type declarations. + { name: 'missing-random-dependency', import: import('missing-random-dependency/nested'), exports: [] }, + ], + 'test schema' + ) + ).rejects.toThrow( + 'Tried to use a test schema in @novu/framework without missing-random-dependency installed. Please install it by running `npm install missing-random-dependency`.' + ); + }); + }); +}); diff --git a/packages/framework/src/utils/import.utils.ts b/packages/framework/src/utils/import.utils.ts new file mode 100644 index 00000000000..e5684c2575b --- /dev/null +++ b/packages/framework/src/utils/import.utils.ts @@ -0,0 +1,38 @@ +import { MissingDependencyError } from '../errors/import.errors'; +import type { ImportRequirement } from '../types/import.types'; + +/** + * Check if the required dependencies are installed and throw an error if not. + * + * @param dependencies - The list of dependencies to check + * @param usageReason - The usage of the dependencies + */ +export const checkDependencies = async ( + dependencies: readonly ImportRequirement[], + usageReason: string +): Promise => { + const missingDependencies = new Set(); + const results = await Promise.allSettled(dependencies.map((dep) => dep.import)); + + results.forEach((result, index) => { + const dep = dependencies[index]; + if (result.status === 'fulfilled') { + const hasAllExports = dep.exports.every((exportName) => result.value[exportName] !== undefined); + + /* + * First way that a dependency isn't available is if the import succeeds + * but the necessary exports are not available. + */ + if (!hasAllExports) { + missingDependencies.add(dep.name); + } + } else { + // Second way that a dependency isn't available is if the import fails. + missingDependencies.add(dep.name); + } + }); + + if (missingDependencies.size > 0) { + throw new MissingDependencyError(usageReason, Array.from(missingDependencies)); + } +}; diff --git a/packages/framework/src/validators/base.validator.ts b/packages/framework/src/validators/base.validator.ts index 3a7f170a634..96acecdb135 100644 --- a/packages/framework/src/validators/base.validator.ts +++ b/packages/framework/src/validators/base.validator.ts @@ -1,4 +1,4 @@ -import type { FromSchema, FromSchemaUnvalidated, JsonSchema, Schema } from '../types/schema.types'; +import type { FromSchema, FromSchemaUnvalidated, 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'; @@ -6,6 +6,13 @@ import { ZodValidator } from './zod.validator'; const zodValidator = new ZodValidator(); const jsonSchemaValidator = new JsonSchemaValidator(); +/** + * Validate data against a schema. + * + * @param schema - The schema to validate the data against. + * @param data - The data to validate. + * @returns The validated data. + */ export const validateData = async < T_Schema extends Schema = Schema, T_Unvalidated extends Record = FromSchemaUnvalidated, @@ -14,20 +21,31 @@ export const validateData = async < schema: T_Schema, data: T_Unvalidated ): Promise> => { - if (zodValidator.canHandle(schema)) { - return zodValidator.validate(data, schema); - } else if (jsonSchemaValidator.canHandle(schema)) { - return jsonSchemaValidator.validate(data, schema); + /** + * TODO: Replace type coercion with async type guard when available. + * + * @see https://github.com/microsoft/typescript/issues/37681 + */ + if (await zodValidator.canHandle(schema)) { + return zodValidator.validate(data, schema as ZodSchema); + } else if (await jsonSchemaValidator.canHandle(schema)) { + return jsonSchemaValidator.validate(data, schema as JsonSchema); } throw new Error('Invalid schema'); }; +/** + * Transform a schema to a JSON schema. + * + * @param schema - The schema to transform. + * @returns The transformed JSON schema. + */ export const transformSchema = async (schema: Schema): Promise => { - if (zodValidator.canHandle(schema)) { - return zodValidator.transformToJsonSchema(schema); - } else if (jsonSchemaValidator.canHandle(schema)) { - return jsonSchemaValidator.transformToJsonSchema(schema); + if (await zodValidator.canHandle(schema)) { + return zodValidator.transformToJsonSchema(schema as ZodSchema); + } else if (await jsonSchemaValidator.canHandle(schema)) { + return jsonSchemaValidator.transformToJsonSchema(schema as JsonSchema); } throw new Error('Invalid schema'); diff --git a/packages/framework/src/validators/json-schema.validator.ts b/packages/framework/src/validators/json-schema.validator.ts index 2a5446d7a9a..4f1cf969eb6 100644 --- a/packages/framework/src/validators/json-schema.validator.ts +++ b/packages/framework/src/validators/json-schema.validator.ts @@ -2,10 +2,18 @@ import Ajv from 'ajv'; import type { ErrorObject, ValidateFunction as AjvValidateFunction } from 'ajv'; import addFormats from 'ajv-formats'; import type { ValidateResult, Validator } from '../types/validator.types'; -import type { FromSchema, FromSchemaUnvalidated, JsonSchema, Schema } from '../types/schema.types'; +import type { FromSchema, FromSchemaUnvalidated, Schema, JsonSchema } from '../types/schema.types'; import { cloneData } from '../utils/clone.utils'; +import { checkDependencies } from '../utils/import.utils'; +import { ImportRequirement } from '../types/import.types'; export class JsonSchemaValidator implements Validator { + /** + * Json schema validation has no required dependencies as they are included in + * the `@novu/framework` package dependencies. + */ + readonly requiredImports: readonly ImportRequirement[] = []; + private readonly ajv: Ajv; /** @@ -27,15 +35,18 @@ export class JsonSchemaValidator implements Validator { this.compiledSchemas = new Map(); } - canHandle(schema: Schema): schema is JsonSchema { - if (typeof schema === 'boolean') return false; + async canHandle(schema: Schema): Promise { + const canHandle = + (schema as JsonSchema).type === 'object' || + !!(schema as JsonSchema).anyOf || + !!(schema as JsonSchema).allOf || + !!(schema as JsonSchema).oneOf; + + if (canHandle) { + await checkDependencies(this.requiredImports, 'JSON schema'); + } - return ( - (schema as Exclude).type === 'object' || - !!(schema as Exclude).anyOf || - !!(schema as Exclude).allOf || - !!(schema as Exclude).oneOf - ); + return canHandle; } async validate< @@ -52,7 +63,6 @@ export class JsonSchemaValidator implements Validator { // ajv mutates the data, so we need to clone it to avoid side effects const clonedData = cloneData(data); - // const valid = validateFn(data); const valid = validateFn(clonedData); if (valid) { diff --git a/packages/framework/src/validators/validator.test.ts b/packages/framework/src/validators/validator.test.ts index c52531a19e1..b43a32c0316 100644 --- a/packages/framework/src/validators/validator.test.ts +++ b/packages/framework/src/validators/validator.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { ZodSchema, z } from 'zod'; +import { z } from 'zod'; import { validateData, transformSchema } from './base.validator'; -import { JsonSchema, Schema } from '../types/schema.types'; +import { Schema, ZodSchema, JsonSchema } from '../types/schema.types'; const schemas = ['zod', 'json'] as const; diff --git a/packages/framework/src/validators/zod.validator.ts b/packages/framework/src/validators/zod.validator.ts index e428b9bf82e..d6cd31a1880 100644 --- a/packages/framework/src/validators/zod.validator.ts +++ b/packages/framework/src/validators/zod.validator.ts @@ -1,11 +1,37 @@ -import { ZodSchema } from 'zod'; - -import type { FromSchema, FromSchemaUnvalidated, JsonSchema, Schema } from '../types/schema.types'; +import type { + FromSchema, + FromSchemaUnvalidated, + Schema, + JsonSchema, + ZodSchemaMinimal, + ZodSchema, +} from '../types/schema.types'; import type { ValidateResult, Validator } from '../types/validator.types'; +import { checkDependencies } from '../utils/import.utils'; +import { ImportRequirement } from '../types/import.types'; export class ZodValidator implements Validator { - canHandle(schema: Schema): schema is ZodSchema { - return (schema as ZodSchema).safeParseAsync !== undefined; + readonly requiredImports: readonly ImportRequirement[] = [ + { + name: 'zod', + import: import('zod'), + exports: ['ZodType'], + }, + { + name: 'zod-to-json-schema', + import: import('zod-to-json-schema'), + exports: ['zodToJsonSchema'], + }, + ]; + + async canHandle(schema: Schema): Promise { + const canHandle = (schema as ZodSchemaMinimal).safeParseAsync !== undefined; + + if (canHandle) { + await checkDependencies(this.requiredImports, 'Zod schema'); + } + + return canHandle; } async validate< @@ -13,7 +39,7 @@ export class ZodValidator implements Validator { T_Unvalidated = FromSchemaUnvalidated, T_Validated = FromSchema, >(data: T_Unvalidated, schema: T_Schema): Promise> { - const result = schema.safeParse(data); + const result = await schema.safeParseAsync(data); if (result.success) { return { success: true, data: result.data as T_Validated }; } else { @@ -28,20 +54,9 @@ export class ZodValidator implements Validator { } async transformToJsonSchema(schema: ZodSchema): Promise { - try { - const { zodToJsonSchema } = await import('zod-to-json-schema'); + const { zodToJsonSchema } = await import('zod-to-json-schema'); - // TODO: zod-to-json-schema is not using JSONSchema7 - return zodToJsonSchema(schema) as JsonSchema; - } catch (error) { - if ((error as Error)?.message?.includes('Cannot find module')) { - // eslint-disable-next-line no-console - console.error( - 'Tried to use a zod schema in @novu/framework without `zod-to-json-schema` installed. ' + - 'Please install it by running `npm install zod-to-json-schema`.' - ); - } - throw error; - } + // TODO: zod-to-json-schema is not using JSONSchema7 + return zodToJsonSchema(schema) as JsonSchema; } }