Skip to content

Commit

Permalink
dynamic config resolver schemas and types
Browse files Browse the repository at this point in the history
  • Loading branch information
Assem-Hafez committed Jan 17, 2025
1 parent 74fba0c commit 546fc8a
Show file tree
Hide file tree
Showing 10 changed files with 349 additions and 86 deletions.
27 changes: 24 additions & 3 deletions src/config/dynamic/dynamic.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<undefined, number>;
GRPC_SERVICES_NAMES: ConfigEnvDefinition<true>;
DYNAMIC: ConfigAsyncResolverDefinition<undefined, number, 'serverStart'>;
DYNAMIC_WITH_ARG: ConfigAsyncResolverDefinition<number, number, 'request'>;
COMPUTED: ConfigSyncResolverDefinition<undefined, [string], 'request'>;
COMPUTED_WITH_ARG: ConfigSyncResolverDefinition<
[string],
[string],
'request'
>;
} = {
CADENCE_WEB_PORT: {
env: 'CADENCE_WEB_PORT',
Expand All @@ -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;

Expand Down
25 changes: 25 additions & 0 deletions src/config/dynamic/resolvers/schemas/resolver-schemas.ts
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 13 additions & 2 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
}
14 changes: 12 additions & 2 deletions src/utils/config/__tests__/get-config-value.node.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,6 +11,14 @@ jest.mock('../global-configs-ref', () => ({
} satisfies Partial<LoadedConfigs>,
}));

jest.mock('@/config/dynamic/resolvers/schemas/resolver-schemas', () => ({
COMPUTED: {
args: z.undefined(),
returnType: z.string(),
},
CADENCE_WEB_PORT: 'someValue',
}));

describe('getConfigValue', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand All @@ -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');
});
});
36 changes: 28 additions & 8 deletions src/utils/config/__tests__/get-transformed-configs.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { z } from 'zod';

import {
type ConfigEnvDefinition,
type ConfigSyncResolverDefinition,
Expand All @@ -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',
Expand All @@ -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<undefined, string>;
config2: ConfigSyncResolverDefinition<undefined, string, 'request'>;
} = {
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',
});
Expand Down
116 changes: 97 additions & 19 deletions src/utils/config/__tests__/transform-configs.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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<typeof configs>;

const result = await transformConfigs(configs, resolversSchemas);
expect(result).toEqual({
config1: 'envValue1',
} satisfies LoadedConfigs<typeof configs>);
});

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<typeof configs> = {};

const result = await transformConfigs(configs, resolversSchemas);
expect(result).toEqual({
config2: 'default2',
} satisfies LoadedConfigs<MockConfigDefinitions>);
} satisfies LoadedConfigs<typeof configs>);
});

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<undefined, number, 'serverStart'>;
};

const resolversSchemas: InferResolverSchema<typeof configs> = {
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<typeof configs>);
});

it('should get the resolver for configuration that is evaluated on request', async () => {
const configs = {
config3: { evaluateOn: 'request', resolver: (n) => n },
} satisfies {
config3: ConfigSyncResolverDefinition<number, number, 'request'>;
};

const resolversSchemas: InferResolverSchema<typeof configs> = {
config3: {
args: z.number(),
returnType: z.number(),
},
};

const result = await transformConfigs(configs, resolversSchemas);
expect(result).toEqual({
config3: configs.config3.resolver,
} satisfies LoadedConfigs<typeof configs>);
});

// 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<undefined, number, 'serverStart'>;
};

const resolversSchemas: InferResolverSchema<typeof configs> = {
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/
);
});
});
Loading

0 comments on commit 546fc8a

Please sign in to comment.