Skip to content

Commit

Permalink
Dynamic config definitions and getter (#788)
Browse files Browse the repository at this point in the history
* dynamic config definitions and getter

* enhance get transformed configs types and add unit tests

* fix unit tests
  • Loading branch information
Assem-Hafez authored Jan 15, 2025
1 parent 63d0deb commit 3c02ca8
Show file tree
Hide file tree
Showing 15 changed files with 435 additions and 0 deletions.
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"react-intersection-observer": "^9.8.1",
"react-json-view-lite": "^1.4.0",
"react-virtuoso": "^4.10.4",
"server-only": "^0.0.1",
"styletron-engine-monolithic": "^1.0.0",
"styletron-react": "^6.1.1",
"use-between": "^1.3.5",
Expand Down
47 changes: 47 additions & 0 deletions src/config/dynamic/dynamic.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import 'server-only';

import type {
ConfigAsyncResolverDefinition,
ConfigEnvDefinition,
ConfigSyncResolverDefinition,
} from '../../utils/config/config.types';

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>;
} = {
CADENCE_WEB_PORT: {
env: 'CADENCE_WEB_PORT',
//Fallback to nextjs default port if CADENCE_WEB_PORT is not provided
default: '3000',
},
ADMIN_SECURITY_TOKEN: {
env: 'CADENCE_ADMIN_SECURITY_TOKEN',
default: '',
},
GRPC_PROTO_DIR_BASE_PATH: {
env: 'GRPC_PROTO_DIR_BASE_PATH',
default: 'src/__generated__/idl/proto',
},
GRPC_SERVICES_NAMES: {
env: 'NEXT_PUBLIC_CADENCE_GRPC_SERVICES_NAMES',
default: 'cadence-frontend',
},
// For testing purposes
DYNAMIC: {
resolver: async () => {
return 1;
},
},
COMPUTED: {
resolver: (value: [string]) => {
return value;
},
},
} as const;

export default dynamicConfigs;
5 changes: 5 additions & 0 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import getTransformedConfigs from './utils/config/get-transformed-configs';
import { setLoadedGlobalConfigs } from './utils/config/global-configs-ref';
import { registerLoggers } from './utils/logger';

export async function register() {
registerLoggers();
if (process.env.NEXT_RUNTIME === 'nodejs') {
setLoadedGlobalConfigs(getTransformedConfigs());
}
}
30 changes: 30 additions & 0 deletions src/utils/config/__tests__/get-config-value.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { type LoadedConfigs } from '../config.types';
import getConfigValue from '../get-config-value';
import { loadedGlobalConfigs } from '../global-configs-ref';

jest.mock('../global-configs-ref', () => ({
loadedGlobalConfigs: {
COMPUTED: jest.fn(),
CADENCE_WEB_PORT: 'someValue',
} satisfies Partial<LoadedConfigs>,
}));

describe('getConfigValue', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('returns the value directly if it is not a function', async () => {
const result = await getConfigValue('CADENCE_WEB_PORT');
expect(result).toBe('someValue');
});

it('calls the function with the provided argument and returns the result', async () => {
const mockFn = loadedGlobalConfigs.COMPUTED as jest.Mock;
mockFn.mockResolvedValue('resolvedValue');

const result = await getConfigValue('COMPUTED', ['arg']);
expect(mockFn).toHaveBeenCalledWith(['arg']);
expect(result).toBe('resolvedValue');
});
});
21 changes: 21 additions & 0 deletions src/utils/config/__tests__/get-config-value.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import getConfigValue from '../get-config-value';

jest.mock('../global-configs-ref', () => ({
loadedGlobalConfigs: {
CADENCE_WEB_PORT: 'someValue',
},
}));

describe('getConfigValue', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('throws an error when invoked in the browser', async () => {
(global as any).window = {};
await expect(getConfigValue('CADENCE_WEB_PORT', undefined)).rejects.toThrow(
'getConfigValue cannot be invoked on browser'
);
delete (global as any).window;
});
});
61 changes: 61 additions & 0 deletions src/utils/config/__tests__/get-transformed-configs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
type ConfigEnvDefinition,
type ConfigSyncResolverDefinition,
} from '../config.types';
import transformConfigs from '../transform-configs';

describe('transformConfigs', () => {
const originalEnv = process.env;
beforeEach(() => {
jest.resetModules();
process.env = {
...originalEnv,
$$$_MOCK_ENV_CONFIG1: 'envValue1',
};
});

afterEach(() => {
process.env = originalEnv;
});

it('should add resolver function as is', () => {
const configDefinitions: {
config1: ConfigEnvDefinition;
config2: ConfigSyncResolverDefinition<undefined, string>;
} = {
config1: { env: '$$$_MOCK_ENV_CONFIG1', default: 'default1' },
config2: {
resolver: () => 'resolvedValue',
},
};
const result = transformConfigs(configDefinitions);
expect(result).toEqual({
config1: 'envValue1',
config2: configDefinitions.config2.resolver,
});
});

it('should return environment variable value when present', () => {
const configDefinitions: {
config1: ConfigEnvDefinition;
} = {
config1: { env: '$$$_MOCK_ENV_CONFIG1', default: 'default1' },
};
const result = transformConfigs(configDefinitions);
expect(result).toEqual({
config1: 'envValue1',
});
});

it('should return default value when environment variable is not present', () => {
const configDefinitions: {
config3: ConfigEnvDefinition;
} = {
config3: { env: '$$$_MOCK_ENV_CONFIG3', default: 'default3' },
};
const result = transformConfigs(configDefinitions);
expect(result).toEqual({
config3: 'default3',
});
});
});
51 changes: 51 additions & 0 deletions src/utils/config/__tests__/global-ref.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import GlobalRef from '../global-ref';

describe('GlobalRef', () => {
let originalGlobal: any;

beforeEach(() => {
originalGlobal = global;
global = { ...global };
});

afterEach(() => {
global = originalGlobal;
});

it('should set and get the value correctly', () => {
const globalRef = new GlobalRef<number>('test-unique-name');
globalRef.value = 42;
expect(globalRef.value).toBe(42);
});

it('should return undefined if value is not set', () => {
const globalRef = new GlobalRef<number>('another-unique-name');
expect(globalRef.value).toBeUndefined();
});

it('should handle different types of values', () => {
const stringRef = new GlobalRef<string>('string-unique-name');
stringRef.value = 'test string';
expect(stringRef.value).toBe('test string');

const objectRef = new GlobalRef<{ key: string }>('object-unique-name');
objectRef.value = { key: 'value' };
expect(objectRef.value).toEqual({ key: 'value' });
});

it('should use the same symbol for the same unique name', () => {
const ref1 = new GlobalRef<number>('shared-unique-name');
const ref2 = new GlobalRef<number>('shared-unique-name');
ref1.value = 100;
expect(ref2.value).toBe(100);
});

it('should use different symbols for different unique names', () => {
const ref1 = new GlobalRef<number>('unique-name-1');
const ref2 = new GlobalRef<number>('unique-name-2');
ref1.value = 100;
ref2.value = 200;
expect(ref1.value).toBe(100);
expect(ref2.value).toBe(200);
});
});
39 changes: 39 additions & 0 deletions src/utils/config/__tests__/transform-configs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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
);

describe('getTransformedConfigs', () => {
const originalEnv = process.env;
beforeEach(() => {
jest.resetModules();
process.env = {
...originalEnv,
$$$_MOCK_ENV_CONFIG1: 'envValue1',
$$$_MOCK_ENV_CONFIG2: '',
};
});

afterEach(() => {
process.env = originalEnv;
});

it('should return transformed dynamic configs', () => {
const result = getTransformedConfigs();
expect(result).toEqual({
config1: 'envValue1',
config2: 'default2',
} satisfies LoadedConfigs<MockConfigDefinitions>);
});
});
80 changes: 80 additions & 0 deletions src/utils/config/config.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { type z } from 'zod';

import type dynamicConfigs from '@/config/dynamic/dynamic.config';

export type ConfigAsyncResolverDefinition<Args, ReturnType> = {
resolver: (args: Args) => Promise<ReturnType>;
// isPublic?: boolean; // would be implemented in upcoming PR
};

export type ConfigSyncResolverDefinition<Args, ReturnType> = {
resolver: (args: Args) => ReturnType;
// forceSync?: boolean; // would be replaced in upcoming PR
// isPublic?: boolean; // would be implemented in upcoming PR
};

export type ConfigEnvDefinition = {
env: string;
default: string;
// forceSync?: boolean; // would be replaced in upcoming PR
// isPublic?: boolean; // would be implemented in upcoming PR
};

export type ConfigDefinition =
| ConfigAsyncResolverDefinition<any, any>
| ConfigSyncResolverDefinition<any, any>
| ConfigEnvDefinition;

export type ConfigDefinitionRecords = Record<string, ConfigDefinition>;

type InferLoadedConfig<T extends Record<string, any>> = {
[K in keyof T]: T[K] extends ConfigEnvDefinition
? string // If it's an env definition, the value is a string
: T[K] extends ConfigSyncResolverDefinition<infer Args, infer ReturnType>
? (args: Args) => ReturnType // If it's a sync resolver, it's a function with matching signature
: T[K] extends ConfigAsyncResolverDefinition<infer Args, infer ReturnType>
? (args: Args) => Promise<ReturnType> // If it's an async resolver, it's a promise-returning function
: never; // If it doesn't match any known type, it's never
};

export type LoadedConfigs<
C extends ConfigDefinitionRecords = typeof dynamicConfigs,
> = InferLoadedConfig<C>;

export type ArgOfConfigResolver<K extends keyof LoadedConfigs> =
LoadedConfigs[K] extends (args: any) => any
? Parameters<LoadedConfigs[K]>[0]
: undefined;

export type LoadedConfigValue<K extends keyof LoadedConfigs> =
LoadedConfigs[K] extends (args: any) => any
? ReturnType<LoadedConfigs[K]>
: string;

export type ConfigKeysWithArgs = {
[K in keyof LoadedConfigs]: LoadedConfigs[K] extends (args: undefined) => any
? never
: LoadedConfigs[K] extends (args: any) => any
? K
: never;
}[keyof LoadedConfigs];

export type ConfigKeysWithoutArgs = Exclude<
keyof LoadedConfigs,
ConfigKeysWithArgs
>;

type ResolverType<Args, ReturnType> =
| ConfigSyncResolverDefinition<Args, ReturnType>
| ConfigAsyncResolverDefinition<Args, ReturnType>;

export type InferResolverSchema<Definitions extends Record<string, any>> = {
[Key in keyof Definitions]: Definitions[Key] extends ResolverType<
infer Args,
infer ReturnType
>
? { args: z.ZodType<Args>; returnType: z.ZodType<ReturnType> }
: never;
};

export type ResolverSchemas = InferResolverSchema<typeof dynamicConfigs>;
Loading

0 comments on commit 3c02ca8

Please sign in to comment.