diff --git a/packages/nodes-base/types.d.ts b/packages/nodes-base/types.d.ts new file mode 100644 index 0000000000..a3dd627cc5 --- /dev/null +++ b/packages/nodes-base/types.d.ts @@ -0,0 +1,20 @@ +import type { StrapiApiCredential } from './credentials/StrapiApi.credentials'; +import type { StrapiTokenApiCredential } from './credentials/StrapiTokenApi.credentials'; + +type CredentialSchemaMap = { + strapiApi: StrapiApiCredential; + strapiTokenApi: StrapiTokenApiCredential; +}; + +declare module 'n8n-workflow' { + interface FunctionsBase { + getCredentials( + type: Type, + itemIndex?: number, + ): Promise< + Type extends keyof CredentialSchemaMap + ? CredentialSchemaMap[Type] + : ICredentialDataDecryptedObject + >; + } +} diff --git a/packages/nodes-base/utils/CredentialSchema.ts b/packages/nodes-base/utils/CredentialSchema.ts new file mode 100644 index 0000000000..453aceade4 --- /dev/null +++ b/packages/nodes-base/utils/CredentialSchema.ts @@ -0,0 +1,236 @@ +import type { INodeProperties } from 'n8n-workflow'; +import z, { type ZodOptional, type ZodType } from 'zod'; + +function isObject(value: unknown): value is object { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function removeUndefinedProperties(obj: T): T { + for (const [key, value] of Object.entries(obj)) { + if (value === undefined) { + delete (obj as Record)[key]; + } else if (isObject(value)) { + removeUndefinedProperties(value); + if (Object.keys(value).length === 0) { + delete (obj as Record)[key]; + } + } + } + return obj; +} + +class CredentialSchemaRootObject< + M extends BaseMetadata, + S extends ZodType | null, + T extends { [k: string]: CredentialSchemaProperty } = {}, +> { + constructor(private shape: T) {} + + validate(data: Data) { + return this.toZodSchema().safeParse(data); + } + + toZodSchema() { + return z.object( + Object.fromEntries( + Object.entries(this.shape) + .filter(([_, property]) => property.toZodSchema()) + .map(([key, property]) => [key, property.toZodSchema()]), + ) as ZodifyObject, + ); + } + + getProperty(key: K): T[K]['metadata'] { + return this.shape[key].metadata; + } + + toNodeProperties() { + return Object.entries(this.shape).map(([key, schema]) => schema.toNodeProperties(key)); + } +} + +type ToZodSchemaReturnType< + M extends BaseMetadata = BaseMetadata, + S extends ZodType | null = ZodType, +> = M['optional'] extends true ? (S extends null ? null : ZodOptional>) : S; + +abstract class CredentialSchemaProperty< + M extends BaseMetadata = BaseMetadata, + S extends ZodType | null = null, +> { + constructor( + public metadata: M, + public schema: S, + ) {} + + toZodSchema(): ToZodSchemaReturnType { + if (this.schema && this.metadata.optional) { + return this.schema.optional() as ToZodSchemaReturnType; + } + + return this.schema as ToZodSchemaReturnType; + } + + toNodeProperties(name: string): INodeProperties { + return removeUndefinedProperties({ + name, + displayName: this.metadata.label, + description: this.metadata.description, + default: '', + type: 'string', + }); + } +} + +class CredentialSchemaString< + S extends ZodType, + M extends StringMetadata, +> extends CredentialSchemaProperty { + constructor( + public metadata: M, + schema: S, + ) { + super(metadata, schema); + } + + toNodeProperties(name: string): INodeProperties { + return removeUndefinedProperties({ + ...super.toNodeProperties(name), + type: 'string', + placeholder: this.metadata.placeholder, + typeOptions: { password: this.metadata.password }, + }); + } +} + +class CredentialSchemaOptions< + V extends string, + S extends ZodType, + M extends OptionsMetadata, +> extends CredentialSchemaProperty { + constructor( + public metadata: M, + schema: S, + ) { + super(metadata, schema); + } + + toNodeProperties(name: string): INodeProperties { + const { options } = this.metadata; + return removeUndefinedProperties({ + ...super.toNodeProperties(name), + type: 'options', + options: options.map((option) => ({ + name: option.label, + value: option.value, + description: option.description, + })), + default: options.find((option) => option.default)?.value ?? options[0].value, + }); + } +} + +class CredentialSchemaNotice extends CredentialSchemaProperty { + constructor(public notice: string) { + super({ label: notice }, null); + } + + toNodeProperties(name: string): INodeProperties { + return { + ...super.toNodeProperties(name), + type: 'notice', + }; + } +} + +type BaseMetadata = { + label: string; + description?: string; + default?: unknown; + optional?: boolean; +}; + +type StringMetadata = BaseMetadata & + Partial<{ + password: boolean; + placeholder: string; + default: string; + }>; + +type Option = { + label: string; + value: V; + default?: boolean; + description?: string; +}; + +type NonEmptyArray = [T, ...T[]]; +type OptionsMetadata = BaseMetadata & { options: NonEmptyArray> }; + +type Zodify< + M extends BaseMetadata, + S extends ZodType | null, + T extends CredentialSchemaProperty, +> = ReturnType extends z.ZodType ? ReturnType : never; + +type ZodifyObject< + M extends BaseMetadata, + S extends ZodType | null, + T extends { [k: string]: CredentialSchemaProperty }, +> = { + [K in keyof T as ReturnType extends z.ZodType ? K : never]: Zodify< + M, + S, + T[K] + >; +}; + +type Optional = Omit & Partial>; + +export const CredentialSchema = { + create< + M extends BaseMetadata, + S extends ZodType | null, + T extends { [k: string]: CredentialSchemaProperty }, + >(shape: T) { + return new CredentialSchemaRootObject(shape); + }, + + password(options: Omit, 'password'> = {}) { + return new CredentialSchemaString( + { + password: true, + label: 'Password', + ...options, + }, + z.string(), + ); + }, + // eslint-disable-next-line id-denylist + string(options: M) { + return new CredentialSchemaString(options, z.string()); + }, + url(options: Optional = {}) { + return new CredentialSchemaString({ label: 'URL', ...options }, z.string().url()); + }, + email(options: Optional = {}) { + return new CredentialSchemaString({ label: 'Email', ...options }, z.string().email()); + }, + options>(options: M) { + return new CredentialSchemaOptions( + options, + z.enum( + options.options.map((option) => option.value) as NonEmptyArray< + M['options'][number]['value'] + >, + ), + ); + }, + notice(notice: string) { + return new CredentialSchemaNotice(notice); + }, +}; + +export type InferCredentialSchema< + T extends CredentialSchemaRootObject, +> = z.infer>; diff --git a/packages/nodes-base/utils/__tests__/CredentialSchema.test.ts b/packages/nodes-base/utils/__tests__/CredentialSchema.test.ts new file mode 100644 index 0000000000..f285507c69 --- /dev/null +++ b/packages/nodes-base/utils/__tests__/CredentialSchema.test.ts @@ -0,0 +1,131 @@ +import { CredentialSchema } from '../CredentialSchema'; + +describe('CredentialSchema', () => { + test('should convert Strapi credential to node properties', () => { + expect( + CredentialSchema.create({ + notice: CredentialSchema.notice( + 'Make sure you are using a user account not an admin account', + ), + email: CredentialSchema.email({ placeholder: 'name@email.com' }), + password: CredentialSchema.password(), + url: CredentialSchema.url({ + placeholder: 'https://api.example.com', + }), + apiVersion: CredentialSchema.options({ + label: 'API Version', + description: 'The version of api to be used', + options: [ + { + label: 'Version 4', + value: 'v4', + description: 'API version supported by Strapi 4', + }, + { + label: 'Version 3', + value: 'v3', + default: true, + description: 'API version supported by Strapi 3', + }, + ], + }), + }).toNodeProperties(), + ).toEqual([ + { + displayName: 'Make sure you are using a user account not an admin account', + name: 'notice', + type: 'notice', + default: '', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + placeholder: 'name@email.com', + default: '', + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + placeholder: 'https://api.example.com', + }, + { + displayName: 'API Version', + name: 'apiVersion', + default: 'v3', + type: 'options', + description: 'The version of api to be used', + options: [ + { + name: 'Version 4', + value: 'v4', + description: 'API version supported by Strapi 4', + }, + { + name: 'Version 3', + value: 'v3', + description: 'API version supported by Strapi 3', + }, + ], + }, + ]); + }); + + test('should validate credentials', () => { + const schema = CredentialSchema.create({ + notice: CredentialSchema.notice('Notice'), + email: CredentialSchema.email(), + password: CredentialSchema.password(), + url: CredentialSchema.url(), + apiVersion: CredentialSchema.options({ + label: 'API Version', + options: [ + { + label: 'Version 4', + value: 'v4', + }, + { + label: 'Version 3', + value: 'v3', + default: true, + }, + ], + }), + }); + + const validData = { + email: 'foo@x.com', + url: 'https://google.com', + password: 'foo', + apiVersion: 'v3', + }; + expect(schema.validate(validData).success).toEqual(true); + + expect( + schema.validate({ + ...validData, + email: 'hello world', + }).success, + ).toEqual(false); + + expect( + schema.validate({ + ...validData, + url: 'hello world', + }).success, + ).toEqual(false); + + expect(schema.validate({}).success).toEqual(false); + }); +});