Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamic config resolver schemas and types #793

Open
wants to merge 1 commit into
base: release/4.0.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these are test configs, it would be good to add a comment above them like you did on Line 41.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO the best option would be to have a separate config file specifically for tests

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: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these are test configs anyway, "ASYNC" and "SYNC" would be more meaningful than "DYNAMIC" and "COMPUTED"

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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
Loading