Skip to content

Commit c8d8100

Browse files
Assem-HafezAssem-Uber
authored andcommitted
API for accessing public config (#796)
* get config api * fix mock * fix types for transfrom-config test
1 parent b536b08 commit c8d8100

File tree

11 files changed

+305
-34
lines changed

11 files changed

+305
-34
lines changed

src/app/api/config/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { type NextRequest } from 'next/server';
2+
3+
import getConfig from '@/route-handlers/get-config/get-config';
4+
5+
export async function GET(request: NextRequest) {
6+
return getConfig(request);
7+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { NextRequest } from 'next/server';
2+
3+
import getConfigValue from '@/utils/config/get-config-value';
4+
5+
import getConfig from '../get-config';
6+
import getConfigValueQueryParamsSchema from '../schemas/get-config-query-params-schema';
7+
8+
jest.mock('../schemas/get-config-query-params-schema');
9+
jest.mock('@/utils/config/get-config-value');
10+
11+
describe('getConfig', () => {
12+
beforeEach(() => {
13+
jest.clearAllMocks();
14+
});
15+
16+
it('should return 400 if query parameters are invalid', async () => {
17+
(getConfigValueQueryParamsSchema.safeParse as jest.Mock).mockReturnValue({
18+
data: null,
19+
error: { errors: ['Invalid query parameters'] },
20+
});
21+
22+
const { res } = await setup({
23+
configKey: 'testKey',
24+
jsonArgs: '',
25+
});
26+
const responseJson = await res.json();
27+
expect(responseJson).toEqual(
28+
expect.objectContaining({
29+
message: 'Invalid values provided for config key/args',
30+
})
31+
);
32+
});
33+
34+
it('should return config value if query parameters are valid', async () => {
35+
(getConfigValueQueryParamsSchema.safeParse as jest.Mock).mockReturnValue({
36+
data: { configKey: 'testKey', jsonArgs: '{}' },
37+
error: null,
38+
});
39+
(getConfigValue as jest.Mock).mockResolvedValue('value');
40+
41+
const { res } = await setup({
42+
configKey: 'testKey',
43+
jsonArgs: '{}',
44+
});
45+
46+
expect(getConfigValue).toHaveBeenCalledWith('testKey', '{}');
47+
const responseJson = await res.json();
48+
expect(responseJson).toEqual('value');
49+
});
50+
51+
it('should handle errors from getConfigValue', async () => {
52+
(getConfigValueQueryParamsSchema.safeParse as jest.Mock).mockReturnValue({
53+
data: { configKey: 'testKey', jsonArgs: '{}' },
54+
error: null,
55+
});
56+
(getConfigValue as jest.Mock).mockRejectedValue(new Error('Config error'));
57+
58+
await expect(
59+
setup({
60+
configKey: 'testKey',
61+
jsonArgs: '',
62+
})
63+
).rejects.toThrow('Config error');
64+
});
65+
});
66+
67+
async function setup({
68+
configKey,
69+
jsonArgs,
70+
}: {
71+
configKey: string;
72+
jsonArgs: string;
73+
error?: true;
74+
}) {
75+
const res = await getConfig(
76+
new NextRequest(
77+
`http://localhost?configKey=${configKey}&jsonArgs=${jsonArgs}`,
78+
{
79+
method: 'GET',
80+
}
81+
)
82+
);
83+
84+
return { res };
85+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { NextResponse, type NextRequest } from 'next/server';
2+
3+
import getConfigValue from '@/utils/config/get-config-value';
4+
5+
import getConfigValueQueryParamsSchema from './schemas/get-config-query-params-schema';
6+
7+
export default async function getConfig(request: NextRequest) {
8+
const { data: queryParams, error } =
9+
getConfigValueQueryParamsSchema.safeParse(
10+
Object.fromEntries(request.nextUrl.searchParams)
11+
);
12+
13+
if (error) {
14+
return NextResponse.json(
15+
{
16+
message: 'Invalid values provided for config key/args',
17+
cause: error.errors,
18+
},
19+
{
20+
status: 400,
21+
}
22+
);
23+
}
24+
25+
const { configKey, jsonArgs } = queryParams;
26+
const res = await getConfigValue(configKey, jsonArgs);
27+
28+
return NextResponse.json(res);
29+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { z } from 'zod';
2+
3+
import dynamicConfigs from '@/config/dynamic/dynamic.config';
4+
import resolverSchemas from '@/config/dynamic/resolvers/schemas/resolver-schemas';
5+
import {
6+
type PublicDynamicConfigKeys,
7+
type ArgsOfLoadedConfigsResolvers,
8+
} from '@/utils/config/config.types';
9+
10+
const publicConfigKeys = Object.entries(dynamicConfigs)
11+
.filter(([_, d]) => d.isPublic)
12+
.map(([k]) => k) as PublicDynamicConfigKeys[];
13+
14+
const getConfigValueQueryParamsSchema = z
15+
.object({
16+
configKey: z.string(),
17+
jsonArgs: z.string().optional(),
18+
})
19+
.transform((data, ctx) => {
20+
const configKey = data.configKey as PublicDynamicConfigKeys;
21+
22+
// validate configKey
23+
if (!publicConfigKeys.includes(configKey)) {
24+
ctx.addIssue({
25+
code: z.ZodIssueCode.invalid_enum_value,
26+
options: publicConfigKeys,
27+
received: configKey,
28+
fatal: true,
29+
});
30+
31+
return z.NEVER;
32+
}
33+
34+
// parse jsonArgs
35+
let parsedArgs;
36+
try {
37+
parsedArgs = data.jsonArgs
38+
? JSON.parse(decodeURIComponent(data.jsonArgs))
39+
: undefined;
40+
} catch {
41+
ctx.addIssue({ code: 'custom', message: 'Invalid JSON' });
42+
return z.NEVER;
43+
}
44+
45+
// validate jsonArgs
46+
const configKeyForSchema = configKey as keyof typeof resolverSchemas;
47+
let validatedArgs = parsedArgs;
48+
if (resolverSchemas[configKeyForSchema]) {
49+
const schema = resolverSchemas[configKey as keyof typeof resolverSchemas];
50+
const { error, data } = schema.args.safeParse(parsedArgs);
51+
validatedArgs = data;
52+
if (error) {
53+
ctx.addIssue({
54+
code: z.ZodIssueCode.custom,
55+
message: `Invalid jsonArgs type provided. ${error.errors[0].message}`,
56+
fatal: true,
57+
});
58+
return z.NEVER;
59+
}
60+
}
61+
const result: {
62+
configKey: PublicDynamicConfigKeys;
63+
jsonArgs: Pick<
64+
ArgsOfLoadedConfigsResolvers,
65+
PublicDynamicConfigKeys
66+
>[PublicDynamicConfigKeys];
67+
} = {
68+
configKey,
69+
jsonArgs: validatedArgs,
70+
};
71+
return result;
72+
});
73+
74+
export default getConfigValueQueryParamsSchema;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { type LoadedConfigResolvedValues } from '../config.types';
2+
3+
const mockResolvedConfigValues: LoadedConfigResolvedValues = {
4+
DYNAMIC: 2,
5+
ADMIN_SECURITY_TOKEN: 'mock-secret',
6+
CADENCE_WEB_PORT: '3000',
7+
COMPUTED: ['mock-computed'],
8+
COMPUTED_WITH_ARG: ['mock-arg'],
9+
DYNAMIC_WITH_ARG: 5,
10+
GRPC_PROTO_DIR_BASE_PATH: 'mock/path/to/grpc/proto',
11+
GRPC_SERVICES_NAMES: 'mock-grpc-service-name',
12+
};
13+
export default mockResolvedConfigValues;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import mockResolvedConfigValues from '../__fixtures__/resolved-config-values';
2+
import { type LoadedConfigResolvedValues } from '../config.types';
3+
4+
export default jest.fn(function <K extends keyof LoadedConfigResolvedValues>(
5+
key: K
6+
) {
7+
return Promise.resolve(mockResolvedConfigValues[key]);
8+
});

src/utils/config/__tests__/transform-configs.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { z } from 'zod';
33
import {
44
type InferResolverSchema,
55
type ConfigEnvDefinition,
6-
type LoadedConfigs,
76
type ConfigSyncResolverDefinition,
87
type ConfigAsyncResolverDefinition,
8+
type InferLoadedConfig,
99
} from '../config.types';
1010
import transformConfigs from '../transform-configs';
1111

@@ -33,7 +33,7 @@ describe('getTransformedConfigs', () => {
3333
const result = await transformConfigs(configs, resolversSchemas);
3434
expect(result).toEqual({
3535
config1: 'envValue1',
36-
} satisfies LoadedConfigs<typeof configs>);
36+
} satisfies InferLoadedConfig<typeof configs>);
3737
});
3838

3939
it('should get default value for unset environment variables', async () => {
@@ -46,7 +46,7 @@ describe('getTransformedConfigs', () => {
4646
const result = await transformConfigs(configs, resolversSchemas);
4747
expect(result).toEqual({
4848
config2: 'default2',
49-
} satisfies LoadedConfigs<typeof configs>);
49+
} satisfies InferLoadedConfig<typeof configs>);
5050
});
5151

5252
it('should get resolved value for configuration that is evaluated on server start', async () => {
@@ -67,7 +67,7 @@ describe('getTransformedConfigs', () => {
6767
expect(configs.config3.resolver).toHaveBeenCalledWith(undefined);
6868
expect(result).toEqual({
6969
config3: 3,
70-
} satisfies LoadedConfigs<typeof configs>);
70+
} satisfies InferLoadedConfig<typeof configs>);
7171
});
7272

7373
it('should get the resolver for configuration that is evaluated on request', async () => {
@@ -87,7 +87,7 @@ describe('getTransformedConfigs', () => {
8787
const result = await transformConfigs(configs, resolversSchemas);
8888
expect(result).toEqual({
8989
config3: configs.config3.resolver,
90-
} satisfies LoadedConfigs<typeof configs>);
90+
} satisfies InferLoadedConfig<typeof configs>);
9191
});
9292

9393
it('should throw an error if the resolved value does not match the schema', async () => {

0 commit comments

Comments
 (0)