mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
chore(core): Credential resolver interface and decorator (#22584)
This commit is contained in:
+344
@@ -0,0 +1,344 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import type { ICredentialContext, ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
|
||||
import type { CredentialResolverConfiguration, ICredentialResolver } from '../credential-resolver';
|
||||
import {
|
||||
CredentialResolver,
|
||||
CredentialResolverEntryMetadata,
|
||||
} from '../credential-resolver-metadata';
|
||||
|
||||
describe('@CredentialResolver decorator', () => {
|
||||
let resolverMetadata: CredentialResolverEntryMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
resolverMetadata = new CredentialResolverEntryMetadata();
|
||||
Container.set(CredentialResolverEntryMetadata, resolverMetadata);
|
||||
});
|
||||
|
||||
it('should register resolver in CredentialResolverEntryMetadata', () => {
|
||||
@CredentialResolver()
|
||||
class TestResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'test.resolver',
|
||||
description: 'Test resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
const registeredResolvers = resolverMetadata.getClasses();
|
||||
|
||||
expect(registeredResolvers).toContain(TestResolver);
|
||||
expect(registeredResolvers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should register multiple resolvers', () => {
|
||||
@CredentialResolver()
|
||||
class FirstResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'first.resolver',
|
||||
description: 'First resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
@CredentialResolver()
|
||||
class SecondResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'second.resolver',
|
||||
description: 'Second resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
@CredentialResolver()
|
||||
class ThirdResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'third.resolver',
|
||||
description: 'Third resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
const registeredResolvers = resolverMetadata.getClasses();
|
||||
|
||||
expect(registeredResolvers).toContain(FirstResolver);
|
||||
expect(registeredResolvers).toContain(SecondResolver);
|
||||
expect(registeredResolvers).toContain(ThirdResolver);
|
||||
expect(registeredResolvers).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should apply Service decorator', () => {
|
||||
@CredentialResolver()
|
||||
class TestResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'test.resolver',
|
||||
description: 'Test resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
expect(Container.has(TestResolver)).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow instantiation of registered resolvers with accessible metadata', () => {
|
||||
@CredentialResolver()
|
||||
class TestResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'oauth.introspection',
|
||||
description: 'OAuth introspection resolver',
|
||||
displayName: 'OAuth Introspection',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
const resolverInstance = Container.get(TestResolver);
|
||||
|
||||
expect(resolverInstance).toBeInstanceOf(TestResolver);
|
||||
expect(resolverInstance.metadata).toEqual({
|
||||
name: 'oauth.introspection',
|
||||
description: 'OAuth introspection resolver',
|
||||
displayName: 'OAuth Introspection',
|
||||
});
|
||||
expect(resolverInstance.metadata.name).toBe('oauth.introspection');
|
||||
});
|
||||
|
||||
it('should register resolvers with different metadata', () => {
|
||||
@CredentialResolver()
|
||||
class OAuthResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'oauth.resolver',
|
||||
description: 'OAuth-based credential resolver',
|
||||
displayName: 'OAuth Resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
@CredentialResolver()
|
||||
class StubResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'stub.resolver',
|
||||
description: 'Stub resolver for testing',
|
||||
displayName: 'Stub Resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
const registeredResolvers = resolverMetadata.getClasses();
|
||||
const oauthResolver = Container.get(OAuthResolver);
|
||||
const stubResolver = Container.get(StubResolver);
|
||||
|
||||
expect(registeredResolvers).toHaveLength(2);
|
||||
expect(oauthResolver.metadata.name).toBe('oauth.resolver');
|
||||
expect(oauthResolver.metadata.displayName).toBe('OAuth Resolver');
|
||||
expect(stubResolver.metadata.name).toBe('stub.resolver');
|
||||
expect(stubResolver.metadata.displayName).toBe('Stub Resolver');
|
||||
});
|
||||
|
||||
it('should support resolvers with configuration options', () => {
|
||||
@CredentialResolver()
|
||||
class ConfigurableResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'configurable.resolver',
|
||||
description: 'Resolver with configuration options',
|
||||
options: [
|
||||
{
|
||||
displayName: 'API Endpoint',
|
||||
name: 'apiEndpoint',
|
||||
type: 'string' as const,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
const resolverInstance = Container.get(ConfigurableResolver);
|
||||
|
||||
expect(resolverInstance.metadata.options).toBeDefined();
|
||||
expect(resolverInstance.metadata.options).toHaveLength(1);
|
||||
expect(resolverInstance.metadata.options[0].name).toBe('apiEndpoint');
|
||||
});
|
||||
|
||||
it('should support optional deleteSecret method', () => {
|
||||
@CredentialResolver()
|
||||
class ResolverWithDelete implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'resolver.with.delete',
|
||||
description: 'Resolver with delete support',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async deleteSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
const withDelete = Container.get(ResolverWithDelete);
|
||||
|
||||
expect(withDelete.deleteSecret).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Container, Service } from '@n8n/di';
|
||||
|
||||
import { CredentialResolverClass } from './credential-resolver';
|
||||
|
||||
type CredentialResolverEntry = {
|
||||
class: CredentialResolverClass;
|
||||
};
|
||||
|
||||
/**
|
||||
* Registry service for credential resolver type discovery and instantiation.
|
||||
* Resolver classes decorated with @CredentialResolver() are automatically registered.
|
||||
*/
|
||||
@Service()
|
||||
export class CredentialResolverEntryMetadata {
|
||||
private readonly credentialResolverEntries: Set<CredentialResolverEntry> = new Set();
|
||||
|
||||
/** Registers a credential resolver class. Called automatically by @CredentialResolver() decorator. */
|
||||
register(credentialResolverEntry: CredentialResolverEntry) {
|
||||
this.credentialResolverEntries.add(credentialResolverEntry);
|
||||
}
|
||||
|
||||
/** Returns all registered resolver entries as [index, entry] tuples. */
|
||||
getEntries() {
|
||||
return [...this.credentialResolverEntries.entries()];
|
||||
}
|
||||
|
||||
/** Returns all registered resolver classes. */
|
||||
getClasses() {
|
||||
return [...this.credentialResolverEntries.values()].map((entry) => entry.class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator to mark a class as a credential resolver.
|
||||
* Automatically registers the resolver for discovery and enables dependency injection.
|
||||
*
|
||||
* @example
|
||||
* @CredentialResolver()
|
||||
* class MyResolver implements ICredentialResolver { ... }
|
||||
*/
|
||||
export const CredentialResolver =
|
||||
<T extends CredentialResolverClass>() =>
|
||||
(target: T) => {
|
||||
// Register resolver class for discovery by registry
|
||||
Container.get(CredentialResolverEntryMetadata).register({
|
||||
class: target,
|
||||
});
|
||||
|
||||
// Enable dependency injection for the resolver class
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Service()(target);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Constructable } from '@n8n/di';
|
||||
import type {
|
||||
ICredentialContext,
|
||||
ICredentialDataDecryptedObject,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Configuration object passed to resolver methods. Structure is defined by resolver type's metadata.options.
|
||||
*/
|
||||
export type CredentialResolverConfiguration = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Metadata describing a credential resolver type for UI integration and discovery.
|
||||
*/
|
||||
export interface CredentialResolverMetadata {
|
||||
/** Unique identifier for the resolver type */
|
||||
name: string;
|
||||
|
||||
/** Human-readable description of what this resolver does */
|
||||
description: string;
|
||||
|
||||
/** Optional display name shown in UI. Falls back to name if not provided. */
|
||||
displayName?: string;
|
||||
|
||||
/** Configuration schema using n8n's INodeProperties format for dynamic form rendering */
|
||||
options?: INodeProperties[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Core interface for credential resolver implementations.
|
||||
* Resolvers fetch credential data dynamically based on execution context and configuration.
|
||||
*/
|
||||
export interface ICredentialResolver {
|
||||
/** Metadata for UI integration and resolver discovery */
|
||||
metadata: CredentialResolverMetadata;
|
||||
|
||||
/**
|
||||
* Retrieves credential data for a specific entity from the resolver's storage.
|
||||
* @throws {CredentialResolverDataNotFoundError} When no data exists for the given context
|
||||
* @throws {CredentialResolverError} For other resolver-specific errors
|
||||
*/
|
||||
getSecret(
|
||||
credentialId: string,
|
||||
context: ICredentialContext,
|
||||
options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject>;
|
||||
|
||||
/**
|
||||
* Stores credential data for a specific entity in the resolver's storage.
|
||||
* @throws {CredentialResolverError} When storage operation fails
|
||||
*/
|
||||
setSecret(
|
||||
credentialId: string,
|
||||
context: ICredentialContext,
|
||||
data: ICredentialDataDecryptedObject,
|
||||
options: CredentialResolverConfiguration,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes credential data for a specific entity from the resolver's storage.
|
||||
* Optional - not all resolvers support deletion.
|
||||
* @throws {CredentialResolverError} When deletion operation fails
|
||||
*/
|
||||
deleteSecret?(
|
||||
credentialId: string,
|
||||
context: ICredentialContext,
|
||||
options: CredentialResolverConfiguration,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Validates resolver configuration before saving.
|
||||
* Should verify connectivity, authentication, and configuration structure.
|
||||
* @throws {CredentialResolverValidationError} When configuration is invalid
|
||||
*/
|
||||
validateOptions(options: CredentialResolverConfiguration): Promise<void>;
|
||||
|
||||
/**
|
||||
* Runs initialization logic for the resolver. This might be called multiple times!
|
||||
* Optional - not all resolvers require initialization.
|
||||
*/
|
||||
init?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type helper for credential resolver class constructors.
|
||||
*/
|
||||
export type CredentialResolverClass = Constructable<ICredentialResolver>;
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Base error class for all credential resolver errors.
|
||||
*/
|
||||
export class CredentialResolverError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CredentialResolverError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when no credential data exists for the requested credential and context combination.
|
||||
* Indicates the entity has not stored credentials for this credential type.
|
||||
*/
|
||||
export class CredentialResolverDataNotFoundError extends CredentialResolverError {
|
||||
constructor() {
|
||||
super('No data found available for the requested credential and context combination.');
|
||||
this.name = 'CredentialResolverDataNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when resolver configuration validation fails.
|
||||
* Indicates invalid configuration values or unreachable external services.
|
||||
*/
|
||||
export class CredentialResolverValidationError extends CredentialResolverError {
|
||||
constructor(message: string) {
|
||||
super(`Credential resolver options validation failed: ${message}`);
|
||||
this.name = 'CredentialResolverValidationError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Credential Resolver Module
|
||||
*
|
||||
* Provides interfaces and infrastructure for dynamic credential resolution based on execution context.
|
||||
* Resolvers fetch credential data at runtime from external storage based on entity identity.
|
||||
*/
|
||||
|
||||
export {
|
||||
CredentialResolverEntryMetadata,
|
||||
CredentialResolver,
|
||||
} from './credential-resolver-metadata';
|
||||
export * from './errors';
|
||||
export type * from './credential-resolver';
|
||||
@@ -4,6 +4,7 @@ export { Debounce } from './debounce';
|
||||
export * from './execution-lifecycle';
|
||||
export { Memoized } from './memoized';
|
||||
export * from './context-establishment';
|
||||
export * from './credential-resolver';
|
||||
export * from './module';
|
||||
export * from './multi-main';
|
||||
export * from './pubsub';
|
||||
|
||||
Reference in New Issue
Block a user