diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 019b76f407..4fd3aca193 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -796,6 +796,22 @@ export interface Config { */ mediaType?: 'text/plain' | 'application/json'; + /** + * Optional data format configuration. + * If not provided, defaults to text/plain for both key and value. + */ + dataFormat?: { + /** + * Type of the key in the cache. + * @default 'text/plain' + */ + keyType?: 'text/plain' | 'application/json'; + /** + * Type of the value in the cache. + * @default 'text/plain' + */ + valueType?: 'text/plain' | 'application/json'; + }; /** * Infinispan server host and port configuration. * If this is an array, the client will connect to all servers in the list based on TOPOLOGY_AWARE routing. diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts index 9315593a39..1c26083525 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts @@ -261,26 +261,29 @@ export class CacheManager { const stores: Record = {}; return (pluginId, defaultTtl) => { + if (this.storeOptions?.type !== 'redis') { + throw new Error( + `Internal error: Wrong config type passed to redis factory: ${this.storeOptions?.type}`, + ); + } if (!stores[pluginId]) { - if (this.storeOptions?.type === 'redis') { - const redisOptions = this.storeOptions?.client || { - keyPrefixSeparator: ':', - }; - if (this.storeOptions?.cluster) { - // Create a Redis cluster - const cluster = createCluster(this.storeOptions?.cluster); - stores[pluginId] = new KeyvRedis(cluster, redisOptions); - } else { - // Create a regular Redis connection - stores[pluginId] = new KeyvRedis(this.connection, redisOptions); - } - - // Always provide an error handler to avoid stopping the process - stores[pluginId].on('error', (err: Error) => { - this.logger?.error('Failed to create redis cache client', err); - this.errorHandler?.(err); - }); + const redisOptions = this.storeOptions?.client || { + keyPrefixSeparator: ':', + }; + if (this.storeOptions?.cluster) { + // Create a Redis cluster + const cluster = createCluster(this.storeOptions?.cluster); + stores[pluginId] = new KeyvRedis(cluster, redisOptions); + } else { + // Create a regular Redis connection + stores[pluginId] = new KeyvRedis(this.connection, redisOptions); } + + // Always provide an error handler to avoid stopping the process + stores[pluginId].on('error', (err: Error) => { + this.logger?.error('Failed to create redis cache client', err); + this.errorHandler?.(err); + }); } return new Keyv({ namespace: pluginId, @@ -298,26 +301,29 @@ export class CacheManager { const stores: Record = {}; return (pluginId, defaultTtl) => { + if (this.storeOptions?.type !== 'valkey') { + throw new Error( + `Internal error: Wrong config type passed to valkey factory: ${this.storeOptions?.type}`, + ); + } if (!stores[pluginId]) { - if (this.storeOptions?.type === 'valkey') { - const valkeyOptions = this.storeOptions?.client || { - keyPrefixSeparator: ':', - }; - if (this.storeOptions?.cluster) { - // Create a Valkey cluster (Redis cluster under the hood) - const cluster = createCluster(this.storeOptions?.cluster); - stores[pluginId] = new KeyvValkey(cluster, valkeyOptions); - } else { - // Create a regular Valkey connection - stores[pluginId] = new KeyvValkey(this.connection, valkeyOptions); - } - - // Always provide an error handler to avoid stopping the process - stores[pluginId].on('error', (err: Error) => { - this.logger?.error('Failed to create valkey cache client', err); - this.errorHandler?.(err); - }); + const valkeyOptions = this.storeOptions?.client || { + keyPrefixSeparator: ':', + }; + if (this.storeOptions?.cluster) { + // Create a Valkey cluster (Redis cluster under the hood) + const cluster = createCluster(this.storeOptions?.cluster); + stores[pluginId] = new KeyvValkey(cluster, valkeyOptions); + } else { + // Create a regular Valkey connection + stores[pluginId] = new KeyvValkey(this.connection, valkeyOptions); } + + // Always provide an error handler to avoid stopping the process + stores[pluginId].on('error', (err: Error) => { + this.logger?.error('Failed to create valkey cache client', err); + this.errorHandler?.(err); + }); } return new Keyv({ namespace: pluginId, @@ -366,33 +372,37 @@ export class CacheManager { const stores: Record = {}; return (pluginId, defaultTtl) => { + if (this.storeOptions?.type !== 'infinispan') { + throw new Error( + `Internal error: Wrong config type passed to infinispan factory: ${this.storeOptions?.type}`, + ); + } + if (!stores[pluginId]) { - if (this.storeOptions?.type === 'infinispan') { - // Use sync version for testing environments - const isTest = - process.env.NODE_ENV === 'test' || typeof jest !== 'undefined'; + // Use sync version for testing environments + const isTest = + process.env.NODE_ENV === 'test' || typeof jest !== 'undefined'; - // Create the client promise ONCE and reuse it - const clientPromise: Promise = isTest - ? this.createInfinispanClientSync() - : this.createInfinispanClientAsync(); + // Create the client promise ONCE and reuse it + const clientPromise: Promise = isTest + ? this.createInfinispanClientSync() + : this.createInfinispanClientAsync(); - this.logger?.info( - `Creating Infinispan cache client for plugin ${pluginId} isTest = ${isTest}`, - ); - const storeInstance = new InfinispanKeyvStore({ - clientPromise, - logger: this.logger!, - }); + this.logger?.info( + `Creating Infinispan cache client for plugin ${pluginId} isTest = ${isTest}`, + ); + const storeInstance = new InfinispanKeyvStore({ + clientPromise, + logger: this.logger!, + }); - stores[pluginId] = storeInstance; + stores[pluginId] = storeInstance; - // Always provide an error handler to avoid stopping the process - storeInstance.on('error', (err: Error) => { - this.logger?.error('Failed to create infinispan cache client', err); - this.errorHandler?.(err); - }); - } + // Always provide an error handler to avoid stopping the process + storeInstance.on('error', (err: Error) => { + this.logger?.error('Failed to create infinispan cache client', err); + this.errorHandler?.(err); + }); } return new Keyv({ diff --git a/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts index 58234bb11d..ee49ee2581 100644 --- a/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts +++ b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts @@ -19,6 +19,7 @@ import { RootConfigService, } from '@backstage/backend-plugin-api'; import { + DataFormatOptions, InfinispanAuthOptions, InfinispanCacheStoreOptions, InfinispanClientAuthOptions, @@ -55,29 +56,23 @@ export class InfinispanOptionsMapper { serverConf => ({ host: serverConf.getString('host'), - port: serverConf.getNumber('port'), + port: serverConf.getOptionalNumber('port') ?? 11222, } as InfinispanServerConfig), ); } else if (typeof serversConfig === 'object' && serversConfig !== null) { const serverConf = infinispanConfig.getConfig('servers'); parsedOptions.servers = { - host: serverConf.getString('host'), - port: serverConf.getNumber('port'), + host: serverConf.getOptionalString('host') ?? '127.0.0.1', + port: serverConf.getOptionalString('port') ?? 11222, } as InfinispanServerConfig; } else { - logger?.error( - `Infinispan 'servers' configuration at ${storeConfigPath} must be an object or an array.`, - ); throw new Error( - `Infinispan 'servers' configuration at ${storeConfigPath} is invalid.`, + `Infinispan 'servers' configuration at ${storeConfigPath} is invalid, must be an object or an array.`, ); } } else { - logger?.error( - `Infinispan configuration at ${storeConfigPath} is missing the 'servers' definition.`, - ); - throw new Error( - `Infinispan configuration at ${storeConfigPath} must define 'servers'.`, + logger?.warn( + `Infinispan configuration at ${storeConfigPath} is missing the 'servers' definition, will use client defaults.`, ); } @@ -85,42 +80,35 @@ export class InfinispanOptionsMapper { // This will be used to configure the Infinispan client behavior. const behaviorOptions: Partial = {}; - if (infinispanConfig.has('clusters')) { - const clustersConfig = infinispanConfig.getConfigArray('clusters'); - const clusterOptions: InfinispanClusterConfig[] = []; - clustersConfig?.forEach(clusterConf => { + behaviorOptions.clusters = infinispanConfig + .getOptionalConfigArray('clusters') + ?.map(clusterConf => { const name = clusterConf.getOptionalString('name'); - const cluster: InfinispanClusterConfig = { + return { ...(name && { name }), servers: clusterConf.getConfigArray('servers').map(serverConf => ({ host: serverConf.getString('host'), port: serverConf.getNumber('port'), })), - }; - clusterOptions.push(cluster); - behaviorOptions.clusters = clusterOptions; + } as InfinispanClusterConfig; }); - } // Parse Default Options Start... - if (infinispanConfig.has('version')) { - const clientVersion = infinispanConfig.getString('version'); - if ( - clientVersion === '2.9' || - clientVersion === '2.5' || - clientVersion === '2.2' - ) { - behaviorOptions.version = clientVersion; - } else if (clientVersion !== null && clientVersion !== undefined) { - logger?.warn( - `Invalid Infinispan client version "${clientVersion}" in config at ${storeConfigPath}.version. Must be "2.9", "2.5", or "2.2". It will be ignored, and the client may use a default or fail.`, - ); - } + const clientVersion = + infinispanConfig.getOptionalString('version') ?? '2.9'; + if ( + clientVersion === '2.9' || + clientVersion === '2.5' || + clientVersion === '2.2' + ) { + behaviorOptions.version = clientVersion; + } else if (clientVersion !== null && clientVersion !== undefined) { + logger?.warn( + `Invalid Infinispan client version "${clientVersion}" in config at ${storeConfigPath}.version. Must be "2.9", "2.5", or "2.2". It will be ignored, and the client may use a default or fail.`, + ); } - if (infinispanConfig.has('cacheName')) { - behaviorOptions.cacheName = infinispanConfig.getString('cacheName'); - } + behaviorOptions.cacheName = infinispanConfig.getOptionalString('cacheName'); const mediaType = infinispanConfig.getOptionalString('mediaType'); if (mediaType === 'text/plain' || mediaType === 'application/json') { @@ -131,95 +119,84 @@ export class InfinispanOptionsMapper { ); } - if (infinispanConfig.has('maxRetries')) { - behaviorOptions.maxRetries = infinispanConfig.getNumber('maxRetries'); - } + behaviorOptions.maxRetries = + infinispanConfig.getOptionalNumber('maxRetries'); - if (infinispanConfig.has('topologyUpdates')) { - behaviorOptions.topologyUpdates = - infinispanConfig.getBoolean('topologyUpdates'); - } + behaviorOptions.topologyUpdates = + infinispanConfig.getOptionalBoolean('topologyUpdates') ?? true; // Default Options End... // Parse Authentication and SSL Options - if (infinispanConfig.has('authentication')) { - const authConfig = infinispanConfig.getConfig('authentication'); - const auth: Partial = {}; + const authConfig = infinispanConfig.getOptionalConfig('authentication'); + const auth: Partial = {}; - if (authConfig.has('enabled')) { - auth.enabled = authConfig.getBoolean('enabled'); - } - if (authConfig.has('saslMechanism')) { - auth.saslMechanism = authConfig.getString('saslMechanism'); - } - if (authConfig.has('userName')) { - auth.userName = authConfig.getString('userName'); - } - if (authConfig.has('password')) { - auth.password = authConfig.getString('password'); - } - if (authConfig.has('token')) { - auth.token = authConfig.getString('token'); - } - if (authConfig.has('authzid')) { - auth.authzid = authConfig.getString('authzid'); - } + auth.enabled = authConfig?.getOptionalBoolean('enabled'); + auth.saslMechanism = authConfig?.getOptionalString('saslMechanism'); + auth.userName = authConfig?.getOptionalString('userName'); + auth.password = authConfig?.getOptionalString('password'); + auth.token = authConfig?.getOptionalString('token'); + auth.authzid = authConfig?.getOptionalString('authzid'); - behaviorOptions.authentication = auth as InfinispanAuthOptions; + behaviorOptions.authentication = auth as InfinispanAuthOptions; + + const sslConfig = infinispanConfig.getOptionalConfig('ssl'); + const ssl: Partial = {}; + + ssl.enabled = sslConfig?.getOptionalBoolean('enabled'); + ssl.secureProtocol = sslConfig?.getOptionalString('secureProtocol'); + ssl.trustCerts = sslConfig?.getOptionalStringArray('trustCerts'); + ssl.sniHostName = sslConfig?.getOptionalString('sniHostname'); + + const clientAuth = infinispanConfig.getOptionalConfig('clientAuth'); + const clientAuthOpts: Partial = {}; + + clientAuthOpts.key = clientAuth?.getOptionalString('key'); + clientAuthOpts.passphrase = clientAuth?.getOptionalString('passphrase'); + clientAuthOpts.cert = clientAuth?.getOptionalString('cert'); + + ssl.clientAuth = clientAuthOpts as InfinispanClientAuthOptions; + + const cryptoStore = infinispanConfig.getOptionalConfig('cryptoStore'); + const cryptoStoreOpts: Partial = {}; + + cryptoStoreOpts.key = cryptoStore?.getOptionalString('path'); + cryptoStoreOpts.passphrase = cryptoStore?.getOptionalString('passphrase'); + + ssl.cryptoStore = cryptoStoreOpts as InfinispanClientAuthOptions; + + behaviorOptions.ssl = ssl as InfinispanSslOptions; + + const dataFormat = infinispanConfig.getOptionalConfig('dataFormat'); + const dataFormatOpts: Partial = {}; + + const keyType = dataFormat?.getOptionalString('keyType') ?? 'text/plain'; + const valueType = + dataFormat?.getOptionalString('valueType') ?? 'text/plain'; + + if (keyType === 'text/plain' || keyType === 'application/json') { + dataFormatOpts.keyType = keyType; + } else if (keyType !== null && keyType !== undefined) { + logger?.warn( + `Invalid Infinispan dataFormat.keyType "${keyType}" in config at ${storeConfigPath}.dataFormat.keyType. Must be "text/plain" | "application/json". Not mapped, client will use default 'text/plain'.`, + ); } - if (infinispanConfig.has('ssl')) { - const sslConfig = infinispanConfig.getConfig('ssl'); - const ssl: Partial = {}; - - if (sslConfig.has('enabled')) { - ssl.enabled = sslConfig.getBoolean('enabled'); - } - if (sslConfig.has('secureProtocol')) { - ssl.secureProtocol = sslConfig.getString('secureProtocol'); - } - if (sslConfig.has('trustCerts')) { - ssl.trustCerts = sslConfig.getStringArray('trustCerts'); - } - if (sslConfig.has('sniHostname')) { - ssl.sniHostName = sslConfig.getString('sniHostname'); - } - - if (sslConfig.has('clientAuth')) { - const clientAuth = infinispanConfig.getConfig('clientAuth'); - const clientAuthOpts: Partial = {}; - - if (clientAuth.has('key')) { - clientAuthOpts.key = clientAuth.getString('key'); - } - if (clientAuth.has('passphrase')) { - clientAuthOpts.passphrase = clientAuth.getString('passphrase'); - } - if (clientAuth.has('cert')) { - clientAuthOpts.cert = clientAuth.getString('cert'); - } - ssl.clientAuth = clientAuthOpts as InfinispanClientAuthOptions; - } - - if (sslConfig.has('cryptoStore')) { - const cryptoStore = infinispanConfig.getConfig('cryptoStore'); - const cryptoStoreOpts: Partial = {}; - - if (cryptoStore.has('path')) { - cryptoStoreOpts.key = cryptoStore.getString('path'); - } - if (cryptoStore.has('passphrase')) { - cryptoStoreOpts.passphrase = cryptoStore.getString('passphrase'); - } - ssl.cryptoStore = cryptoStoreOpts as InfinispanClientAuthOptions; - } - - behaviorOptions.ssl = ssl as InfinispanSslOptions; + if (valueType === 'text/plain' || valueType === 'application/json') { + dataFormatOpts.valueType = valueType; + } else if (valueType !== null && valueType !== undefined) { + logger?.warn( + `Invalid Infinispan dataFormat.valueType "${valueType}" in config at ${storeConfigPath}.dataFormat.valueType. Must be "text/plain" | "application/json". Not mapped, client will use default 'text/plain'.`, + ); } - + behaviorOptions.dataFormat = dataFormatOpts as DataFormatOptions; parsedOptions.options = behaviorOptions as InfinispanClientBehaviorOptions; + logger?.debug( + `Parsed Infinispan options from config at ${storeConfigPath}: ${JSON.stringify( + parsedOptions, + )}`, + ); return parsedOptions as InfinispanCacheStoreOptions; } } diff --git a/packages/backend-defaults/src/entrypoints/cache/types.ts b/packages/backend-defaults/src/entrypoints/cache/types.ts index c6175e1b66..522ab2ac3e 100644 --- a/packages/backend-defaults/src/entrypoints/cache/types.ts +++ b/packages/backend-defaults/src/entrypoints/cache/types.ts @@ -135,6 +135,11 @@ export type InfinispanClusterConfig = { servers: InfinispanServerConfig[]; }; +export type DataFormatOptions = { + keyType: 'text/plain' | 'application/json'; + valueType: 'text/plain' | 'application/json'; +}; + /** * Detailed client behavior options for the Infinispan client. * @public @@ -150,4 +155,5 @@ export type InfinispanClientBehaviorOptions = { mediaType?: 'text/plain' | 'application/json'; topologyUpdates?: boolean; clusters?: InfinispanClusterConfig[]; + dataFormat: DataFormatOptions; };