From 546fc8acd0c6067b9ba51a4b07ac3ef4d0b0f2b1 Mon Sep 17 00:00:00 2001 From: Assem Hafez Date: Fri, 17 Jan 2025 08:36:40 +0000 Subject: [PATCH] dynamic config resolver schemas and types --- src/config/dynamic/dynamic.config.ts | 27 +++- .../resolvers/schemas/resolver-schemas.ts | 25 ++++ src/instrumentation.ts | 15 +- .../config/__tests__/get-config-value.node.ts | 14 +- .../__tests__/get-transformed-configs.test.ts | 36 ++++- .../__tests__/transform-configs.test.ts | 116 ++++++++++++--- src/utils/config/config.types.ts | 138 +++++++++++++----- src/utils/config/get-config-value.ts | 28 +++- src/utils/config/get-transformed-configs.ts | 7 +- src/utils/config/transform-configs.ts | 29 +++- 10 files changed, 349 insertions(+), 86 deletions(-) create mode 100644 src/config/dynamic/resolvers/schemas/resolver-schemas.ts diff --git a/src/config/dynamic/dynamic.config.ts b/src/config/dynamic/dynamic.config.ts index 4f1321946..72ef9e11d 100644 --- a/src/config/dynamic/dynamic.config.ts +++ b/src/config/dynamic/dynamic.config.ts @@ -10,9 +10,15 @@ const dynamicConfigs: { CADENCE_WEB_PORT: ConfigEnvDefinition; ADMIN_SECURITY_TOKEN: ConfigEnvDefinition; GRPC_PROTO_DIR_BASE_PATH: ConfigEnvDefinition; - GRPC_SERVICES_NAMES: ConfigEnvDefinition; - COMPUTED: ConfigSyncResolverDefinition<[string], [string]>; - DYNAMIC: ConfigAsyncResolverDefinition; + GRPC_SERVICES_NAMES: ConfigEnvDefinition; + DYNAMIC: ConfigAsyncResolverDefinition; + DYNAMIC_WITH_ARG: ConfigAsyncResolverDefinition; + COMPUTED: ConfigSyncResolverDefinition; + COMPUTED_WITH_ARG: ConfigSyncResolverDefinition< + [string], + [string], + 'request' + >; } = { CADENCE_WEB_PORT: { env: 'CADENCE_WEB_PORT', @@ -30,17 +36,32 @@ const dynamicConfigs: { GRPC_SERVICES_NAMES: { env: 'NEXT_PUBLIC_CADENCE_GRPC_SERVICES_NAMES', default: 'cadence-frontend', + isPublic: true, }, // For testing purposes DYNAMIC: { resolver: async () => { return 1; }, + evaluateOn: 'serverStart', + }, + DYNAMIC_WITH_ARG: { + resolver: async (value: number) => { + return value; + }, + evaluateOn: 'request', }, COMPUTED: { + resolver: () => { + return ['value']; + }, + evaluateOn: 'request', + }, + COMPUTED_WITH_ARG: { resolver: (value: [string]) => { return value; }, + evaluateOn: 'request', }, } as const; diff --git a/src/config/dynamic/resolvers/schemas/resolver-schemas.ts b/src/config/dynamic/resolvers/schemas/resolver-schemas.ts new file mode 100644 index 000000000..cff9336ff --- /dev/null +++ b/src/config/dynamic/resolvers/schemas/resolver-schemas.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { type ResolverSchemas } from '../../../../utils/config/config.types'; + +// Example usage: +const resolverSchemas: ResolverSchemas = { + COMPUTED: { + args: z.undefined(), + returnType: z.tuple([z.string()]), + }, + COMPUTED_WITH_ARG: { + args: z.tuple([z.string()]), + returnType: z.tuple([z.string()]), + }, + DYNAMIC: { + args: z.undefined(), + returnType: z.number(), + }, + DYNAMIC_WITH_ARG: { + args: z.number(), + returnType: z.number(), + }, +}; + +export default resolverSchemas; diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 82973eb1b..4a6c25505 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,10 +1,21 @@ import getTransformedConfigs from './utils/config/get-transformed-configs'; import { setLoadedGlobalConfigs } from './utils/config/global-configs-ref'; -import { registerLoggers } from './utils/logger'; +import logger, { registerLoggers } from './utils/logger'; export async function register() { registerLoggers(); if (process.env.NEXT_RUNTIME === 'nodejs') { - setLoadedGlobalConfigs(getTransformedConfigs()); + try { + const configs = await getTransformedConfigs(); + setLoadedGlobalConfigs(configs); + } catch (e) { + // manually catching and logging the error to prevent the error being replaced + // by "Cannot set property message of [object Object] which has only a getter" + logger.error({ + message: 'Failed to load configs', + cause: String(e), + }); + process.exit(1); // use process.exit to exit without an extra error log from instrumentation + } } } diff --git a/src/utils/config/__tests__/get-config-value.node.ts b/src/utils/config/__tests__/get-config-value.node.ts index 29764a9ec..1ae9886fe 100644 --- a/src/utils/config/__tests__/get-config-value.node.ts +++ b/src/utils/config/__tests__/get-config-value.node.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { type LoadedConfigs } from '../config.types'; import getConfigValue from '../get-config-value'; import { loadedGlobalConfigs } from '../global-configs-ref'; @@ -9,6 +11,14 @@ jest.mock('../global-configs-ref', () => ({ } satisfies Partial, })); +jest.mock('@/config/dynamic/resolvers/schemas/resolver-schemas', () => ({ + COMPUTED: { + args: z.undefined(), + returnType: z.string(), + }, + CADENCE_WEB_PORT: 'someValue', +})); + describe('getConfigValue', () => { beforeEach(() => { jest.clearAllMocks(); @@ -23,8 +33,8 @@ describe('getConfigValue', () => { const mockFn = loadedGlobalConfigs.COMPUTED as jest.Mock; mockFn.mockResolvedValue('resolvedValue'); - const result = await getConfigValue('COMPUTED', ['arg']); - expect(mockFn).toHaveBeenCalledWith(['arg']); + const result = await getConfigValue('COMPUTED'); + expect(mockFn).toHaveBeenCalledWith(undefined); expect(result).toBe('resolvedValue'); }); }); diff --git a/src/utils/config/__tests__/get-transformed-configs.test.ts b/src/utils/config/__tests__/get-transformed-configs.test.ts index 07f9af10e..f6ea8a55d 100644 --- a/src/utils/config/__tests__/get-transformed-configs.test.ts +++ b/src/utils/config/__tests__/get-transformed-configs.test.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { type ConfigEnvDefinition, type ConfigSyncResolverDefinition, @@ -7,7 +9,6 @@ import transformConfigs from '../transform-configs'; describe('transformConfigs', () => { const originalEnv = process.env; beforeEach(() => { - jest.resetModules(); process.env = { ...originalEnv, $$$_MOCK_ENV_CONFIG1: 'envValue1', @@ -18,42 +19,61 @@ describe('transformConfigs', () => { process.env = originalEnv; }); - it('should add resolver function as is', () => { + it('should add resolver function as is', async () => { const configDefinitions: { config1: ConfigEnvDefinition; - config2: ConfigSyncResolverDefinition; + config2: ConfigSyncResolverDefinition; } = { config1: { env: '$$$_MOCK_ENV_CONFIG1', default: 'default1' }, config2: { resolver: () => 'resolvedValue', + evaluateOn: 'request', + }, + }; + + const mockResolvedSchemas = { + config2: { + args: z.undefined(), + returnType: z.string(), }, }; - const result = transformConfigs(configDefinitions); + const result = await transformConfigs( + configDefinitions, + mockResolvedSchemas + ); expect(result).toEqual({ config1: 'envValue1', config2: configDefinitions.config2.resolver, }); }); - it('should return environment variable value when present', () => { + it('should return environment variable value when present', async () => { const configDefinitions: { config1: ConfigEnvDefinition; } = { config1: { env: '$$$_MOCK_ENV_CONFIG1', default: 'default1' }, }; - const result = transformConfigs(configDefinitions); + const mockResolvedSchemas = {}; + const result = await transformConfigs( + configDefinitions, + mockResolvedSchemas + ); expect(result).toEqual({ config1: 'envValue1', }); }); - it('should return default value when environment variable is not present', () => { + it('should return default value when environment variable is not present', async () => { const configDefinitions: { config3: ConfigEnvDefinition; } = { config3: { env: '$$$_MOCK_ENV_CONFIG3', default: 'default3' }, }; - const result = transformConfigs(configDefinitions); + const mockResolvedSchemas = {}; + const result = await transformConfigs( + configDefinitions, + mockResolvedSchemas + ); expect(result).toEqual({ config3: 'default3', }); diff --git a/src/utils/config/__tests__/transform-configs.test.ts b/src/utils/config/__tests__/transform-configs.test.ts index 7980ad016..21c8cd43c 100644 --- a/src/utils/config/__tests__/transform-configs.test.ts +++ b/src/utils/config/__tests__/transform-configs.test.ts @@ -1,23 +1,17 @@ -import { type ConfigEnvDefinition, type LoadedConfigs } from '../config.types'; -import { default as getTransformedConfigs } from '../get-transformed-configs'; - -type MockConfigDefinitions = { - config1: ConfigEnvDefinition; - config2: ConfigEnvDefinition; -}; -jest.mock( - '@/config/dynamic/dynamic.config', - () => - ({ - config1: { env: '$$$_MOCK_ENV_CONFIG1', default: 'default1' }, - config2: { env: '$$$_MOCK_ENV_CONFIG2', default: 'default2' }, - }) satisfies MockConfigDefinitions -); +import { z } from 'zod'; + +import { + type InferResolverSchema, + type ConfigEnvDefinition, + type LoadedConfigs, + type ConfigSyncResolverDefinition, + type ConfigAsyncResolverDefinition, +} from '../config.types'; +import transformConfigs from '../transform-configs'; describe('getTransformedConfigs', () => { const originalEnv = process.env; beforeEach(() => { - jest.resetModules(); process.env = { ...originalEnv, $$$_MOCK_ENV_CONFIG1: 'envValue1', @@ -29,11 +23,95 @@ describe('getTransformedConfigs', () => { process.env = originalEnv; }); - it('should return transformed dynamic configs', () => { - const result = getTransformedConfigs(); + it('should get value for existing non empty environment variables', async () => { + const configs = { + config1: { env: '$$$_MOCK_ENV_CONFIG1', default: 'default1' }, + } satisfies { config1: ConfigEnvDefinition }; + + const resolversSchemas = {} as InferResolverSchema; + + const result = await transformConfigs(configs, resolversSchemas); expect(result).toEqual({ config1: 'envValue1', + } satisfies LoadedConfigs); + }); + + it('should get default value for unset environment variables', async () => { + const configs = { + config2: { env: '$$$_MOCK_ENV_CONFIG2', default: 'default2' }, + } satisfies { config2: ConfigEnvDefinition }; + + const resolversSchemas: InferResolverSchema = {}; + + const result = await transformConfigs(configs, resolversSchemas); + expect(result).toEqual({ config2: 'default2', - } satisfies LoadedConfigs); + } satisfies LoadedConfigs); + }); + + it('should get resolved value for configuration that is evaluated on server start', async () => { + const configs = { + config3: { evaluateOn: 'serverStart', resolver: jest.fn(() => 3) }, + } satisfies { + config3: ConfigSyncResolverDefinition; + }; + + const resolversSchemas: InferResolverSchema = { + config3: { + args: z.undefined(), + returnType: z.number(), + }, + }; + + const result = await transformConfigs(configs, resolversSchemas); + expect(configs.config3.resolver).toHaveBeenCalledWith(undefined); + expect(result).toEqual({ + config3: 3, + } satisfies LoadedConfigs); + }); + + it('should get the resolver for configuration that is evaluated on request', async () => { + const configs = { + config3: { evaluateOn: 'request', resolver: (n) => n }, + } satisfies { + config3: ConfigSyncResolverDefinition; + }; + + const resolversSchemas: InferResolverSchema = { + config3: { + args: z.number(), + returnType: z.number(), + }, + }; + + const result = await transformConfigs(configs, resolversSchemas); + expect(result).toEqual({ + config3: configs.config3.resolver, + } satisfies LoadedConfigs); + }); + + // should throw an error if the resolved value does not match the schema + it('should throw an error if the resolved value does not match the schema', async () => { + const configs = { + config3: { + evaluateOn: 'serverStart', + // @ts-expect-error - intentionally testing invalid return type + resolver: async () => '3', + }, + } satisfies { + config3: ConfigAsyncResolverDefinition; + }; + + const resolversSchemas: InferResolverSchema = { + config3: { + args: z.undefined(), + // @ts-expect-error - intentionally testing invalid return type + returnType: z.number(), + }, + }; + + await expect(transformConfigs(configs, resolversSchemas)).rejects.toThrow( + /Failed to parse config 'config3' resolved value/ + ); }); }); diff --git a/src/utils/config/config.types.ts b/src/utils/config/config.types.ts index ec1139a3e..1bb940428 100644 --- a/src/utils/config/config.types.ts +++ b/src/utils/config/config.types.ts @@ -2,59 +2,132 @@ import { type z } from 'zod'; import type dynamicConfigs from '@/config/dynamic/dynamic.config'; -export type ConfigAsyncResolverDefinition = { - resolver: (args: Args) => Promise; - // isPublic?: boolean; // would be implemented in upcoming PR +type ConfigResolver = Args extends undefined + ? () => ReturnType + : (args: Args) => ReturnType; + +export type ConfigAsyncResolverDefinition< + Args, + ReturnType, + EvalOn extends ResolverEvaluateOn, + IsPublic extends boolean = false, +> = { + resolver: ConfigResolver>; + isPublic?: IsPublic; + evaluateOn: EvalOn; }; -export type ConfigSyncResolverDefinition = { - resolver: (args: Args) => ReturnType; - // forceSync?: boolean; // would be replaced in upcoming PR - // isPublic?: boolean; // would be implemented in upcoming PR +export type ConfigSyncResolverDefinition< + Args, + ReturnType, + EvalOn extends ResolverEvaluateOn, + IsPublic extends boolean = false, +> = { + resolver: ConfigResolver; + isPublic?: IsPublic; + evaluateOn: EvalOn; }; -export type ConfigEnvDefinition = { +export type ConfigEnvDefinition = { env: string; default: string; - // forceSync?: boolean; // would be replaced in upcoming PR - // isPublic?: boolean; // would be implemented in upcoming PR + isPublic?: IsPublic; + evaluateOn?: 'serverStart'; }; export type ConfigDefinition = - | ConfigAsyncResolverDefinition - | ConfigSyncResolverDefinition - | ConfigEnvDefinition; + | ConfigAsyncResolverDefinition + | ConfigSyncResolverDefinition + | ConfigEnvDefinition; export type ConfigDefinitionRecords = Record; -type InferLoadedConfig> = { +type ResolverType> = + | ConfigSyncResolverDefinition + | ConfigAsyncResolverDefinition; + +type ResolverEvaluateOn = Args extends undefined + ? 'serverStart' | 'request' + : 'request'; + +export type InferLoadedConfig> = { [K in keyof T]: T[K] extends ConfigEnvDefinition ? string // If it's an env definition, the value is a string - : T[K] extends ConfigSyncResolverDefinition - ? (args: Args) => ReturnType // If it's a sync resolver, it's a function with matching signature - : T[K] extends ConfigAsyncResolverDefinition - ? (args: Args) => Promise // If it's an async resolver, it's a promise-returning function - : never; // If it doesn't match any known type, it's never + : T[K] extends ResolverType + ? EvalOn extends 'serverStart' + ? ReturnType // If it's a sync resolver with evaluateOn serverStart, return the type directly + : T[K] extends ConfigSyncResolverDefinition< + infer Args, + infer ReturnType, + any + > + ? ConfigResolver // If it's a sync resolver, it's a function with matching signature + : T[K] extends ConfigAsyncResolverDefinition< + infer Args, + infer ReturnType, + any + > + ? ConfigResolver> // If it's an async resolver, it's a promise-returning function + : never // If it doesn't match any known type, it's never + : never; //If it doesn't match any known type, it's never }; +export type InferResolverSchema = { + [Key in keyof Definitions as Definitions[Key] extends ResolverType< + any, + any, + any + > + ? Key + : never]: Definitions[Key] extends ResolverType< + infer Args, + infer ReturnType, + any + > + ? { args: z.ZodType; returnType: z.ZodType } + : never; +}; + +export type ArgsOfConfigResolver< + C extends InferLoadedConfig, + K extends keyof C, +> = C[K] extends (args: any) => any ? Parameters[0] : undefined; + +// Types based on dynamicConfigs const +type LoadedPublicConfigs = { + [K in keyof T]: T[K] extends + | ConfigEnvDefinition + | ConfigAsyncResolverDefinition + | ConfigSyncResolverDefinition + ? IsPublic extends true // If it's an env definition, the value is a string + ? K + : never + : never; //If it doesn't match any known type, it's never +}[keyof LoadedConfigs]; + +export type PublicConfigKeys = LoadedPublicConfigs; + +export type DynamicConfig = typeof dynamicConfigs; + export type LoadedConfigs< C extends ConfigDefinitionRecords = typeof dynamicConfigs, > = InferLoadedConfig; -export type ArgOfConfigResolver = - LoadedConfigs[K] extends (args: any) => any - ? Parameters[0] - : undefined; +export type ArgsOfLoadedConfigResolver = + ArgsOfConfigResolver; export type LoadedConfigValue = LoadedConfigs[K] extends (args: any) => any ? ReturnType - : string; + : LoadedConfigs[K]; export type ConfigKeysWithArgs = { - [K in keyof LoadedConfigs]: LoadedConfigs[K] extends (args: undefined) => any + [K in keyof LoadedConfigs]: LoadedConfigs[K] extends ConfigResolver< + undefined, + any + > ? never - : LoadedConfigs[K] extends (args: any) => any + : LoadedConfigs[K] extends ConfigResolver ? K : never; }[keyof LoadedConfigs]; @@ -64,17 +137,4 @@ export type ConfigKeysWithoutArgs = Exclude< ConfigKeysWithArgs >; -type ResolverType = - | ConfigSyncResolverDefinition - | ConfigAsyncResolverDefinition; - -export type InferResolverSchema> = { - [Key in keyof Definitions]: Definitions[Key] extends ResolverType< - infer Args, - infer ReturnType - > - ? { args: z.ZodType; returnType: z.ZodType } - : never; -}; - export type ResolverSchemas = InferResolverSchema; diff --git a/src/utils/config/get-config-value.ts b/src/utils/config/get-config-value.ts index 0d0102a0b..86238639b 100644 --- a/src/utils/config/get-config-value.ts +++ b/src/utils/config/get-config-value.ts @@ -1,16 +1,19 @@ +import resolverSchemas from '../../config/dynamic/resolvers/schemas/resolver-schemas'; + import type { LoadedConfigValue, LoadedConfigs, - ArgOfConfigResolver, + ArgsOfLoadedConfigResolver, ConfigKeysWithArgs, ConfigKeysWithoutArgs, + ResolverSchemas, } from './config.types'; import { loadedGlobalConfigs } from './global-configs-ref'; // Overload for keys requiring arguments export default async function getConfigValue( key: K, - arg: ArgOfConfigResolver + arg: ArgsOfLoadedConfigResolver ): Promise>; // Overload for keys not requiring arguments (env configs) @@ -21,7 +24,7 @@ export default async function getConfigValue( export default async function getConfigValue( key: K, - arg?: ArgOfConfigResolver + arg?: ArgsOfLoadedConfigResolver ): Promise> { if (typeof window !== 'undefined') { throw new Error('getConfigValue cannot be invoked on browser'); @@ -30,7 +33,24 @@ export default async function getConfigValue( const value = loadedGlobalConfigs[key] as LoadedConfigs[K]; if (typeof value === 'function') { - return (await value(arg)) as LoadedConfigValue; + const k = key as keyof ResolverSchemas; + // validate runtime arguments and resolved value + const { error: argsValidationError, data: validatedArg } = + resolverSchemas[k].args.safeParse(arg); + if (argsValidationError) { + throw new Error( + `Failed to parse config '${k}' arguments: ${argsValidationError.errors[0].message}` + ); + } + const resolvedValue = await value(validatedArg); + const { error: valueValidationError, data: validatedValue } = + resolverSchemas[k].returnType.safeParse(resolvedValue); + if (valueValidationError) { + throw new Error( + `Failed to parse config '${k}' resolved value: ${valueValidationError.errors[0].message}` + ); + } + return validatedValue as LoadedConfigValue; } return value as LoadedConfigValue; diff --git a/src/utils/config/get-transformed-configs.ts b/src/utils/config/get-transformed-configs.ts index 777df4aba..ee1191bfe 100644 --- a/src/utils/config/get-transformed-configs.ts +++ b/src/utils/config/get-transformed-configs.ts @@ -1,12 +1,13 @@ import 'server-only'; import configDefinitions from '../../config/dynamic/dynamic.config'; +import resolverSchemas from '../../config/dynamic/resolvers/schemas/resolver-schemas'; import type { LoadedConfigs } from './config.types'; import transformConfigs from './transform-configs'; -export default function getTransformedConfigs(): LoadedConfigs< - typeof configDefinitions +export default async function getTransformedConfigs(): Promise< + LoadedConfigs > { - return transformConfigs(configDefinitions); + return await transformConfigs(configDefinitions, resolverSchemas); } diff --git a/src/utils/config/transform-configs.ts b/src/utils/config/transform-configs.ts index 1d19b2f39..6b95364c6 100644 --- a/src/utils/config/transform-configs.ts +++ b/src/utils/config/transform-configs.ts @@ -1,12 +1,28 @@ import 'server-only'; -import type { ConfigDefinitionRecords, LoadedConfigs } from './config.types'; +import type { + ConfigDefinitionRecords, + InferResolverSchema, + LoadedConfigs, +} from './config.types'; -export default function transformConfigs( - configDefinitions: C -): LoadedConfigs { - const resolvedConfig = Object.fromEntries( - Object.entries(configDefinitions).map(([key, definition]) => { +export default async function transformConfigs< + C extends ConfigDefinitionRecords, + S extends InferResolverSchema, +>(configDefinitions: C, resolverSchemas: S): Promise> { + const resolvedEntries = await Promise.all( + Object.entries(configDefinitions).map(async ([key, definition]) => { + if ('resolver' in definition && definition.evaluateOn === 'serverStart') { + const resolvedValue = await definition.resolver(undefined); + const { error, data: validatedValue } = + resolverSchemas[key as keyof S].returnType.safeParse(resolvedValue); + if (error) { + throw new Error( + `Failed to parse config '${key}' resolved value: ${error.errors[0].message}` + ); + } + return [key, validatedValue]; + } if ('resolver' in definition) { return [key, definition.resolver]; } @@ -15,6 +31,7 @@ export default function transformConfigs( return [key, envValue === '' ? definition.default : envValue]; }) ); + const resolvedConfig = Object.fromEntries(resolvedEntries); return resolvedConfig; }