Skip to content

Commit

Permalink
API for accessing public config (#796)
Browse files Browse the repository at this point in the history
* get config api

* fix mock

* fix types for transfrom-config test
  • Loading branch information
Assem-Hafez authored Jan 21, 2025
1 parent 443af78 commit 9fc9459
Show file tree
Hide file tree
Showing 11 changed files with 305 additions and 34 deletions.
7 changes: 7 additions & 0 deletions src/app/api/config/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { type NextRequest } from 'next/server';

import getConfig from '@/route-handlers/get-config/get-config';

export async function GET(request: NextRequest) {
return getConfig(request);
}
85 changes: 85 additions & 0 deletions src/route-handlers/get-config/__tests__/get-config.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { NextRequest } from 'next/server';

import getConfigValue from '@/utils/config/get-config-value';

import getConfig from '../get-config';
import getConfigValueQueryParamsSchema from '../schemas/get-config-query-params-schema';

jest.mock('../schemas/get-config-query-params-schema');
jest.mock('@/utils/config/get-config-value');

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

it('should return 400 if query parameters are invalid', async () => {
(getConfigValueQueryParamsSchema.safeParse as jest.Mock).mockReturnValue({
data: null,
error: { errors: ['Invalid query parameters'] },
});

const { res } = await setup({
configKey: 'testKey',
jsonArgs: '',
});
const responseJson = await res.json();
expect(responseJson).toEqual(
expect.objectContaining({
message: 'Invalid values provided for config key/args',
})
);
});

it('should return config value if query parameters are valid', async () => {
(getConfigValueQueryParamsSchema.safeParse as jest.Mock).mockReturnValue({
data: { configKey: 'testKey', jsonArgs: '{}' },
error: null,
});
(getConfigValue as jest.Mock).mockResolvedValue('value');

const { res } = await setup({
configKey: 'testKey',
jsonArgs: '{}',
});

expect(getConfigValue).toHaveBeenCalledWith('testKey', '{}');
const responseJson = await res.json();
expect(responseJson).toEqual('value');
});

it('should handle errors from getConfigValue', async () => {
(getConfigValueQueryParamsSchema.safeParse as jest.Mock).mockReturnValue({
data: { configKey: 'testKey', jsonArgs: '{}' },
error: null,
});
(getConfigValue as jest.Mock).mockRejectedValue(new Error('Config error'));

await expect(
setup({
configKey: 'testKey',
jsonArgs: '',
})
).rejects.toThrow('Config error');
});
});

async function setup({
configKey,
jsonArgs,
}: {
configKey: string;
jsonArgs: string;
error?: true;
}) {
const res = await getConfig(
new NextRequest(
`http://localhost?configKey=${configKey}&jsonArgs=${jsonArgs}`,
{
method: 'GET',
}
)
);

return { res };
}
29 changes: 29 additions & 0 deletions src/route-handlers/get-config/get-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextResponse, type NextRequest } from 'next/server';

import getConfigValue from '@/utils/config/get-config-value';

import getConfigValueQueryParamsSchema from './schemas/get-config-query-params-schema';

export default async function getConfig(request: NextRequest) {
const { data: queryParams, error } =
getConfigValueQueryParamsSchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams)
);

if (error) {
return NextResponse.json(
{
message: 'Invalid values provided for config key/args',
cause: error.errors,
},
{
status: 400,
}
);
}

const { configKey, jsonArgs } = queryParams;
const res = await getConfigValue(configKey, jsonArgs);

return NextResponse.json(res);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { z } from 'zod';

import dynamicConfigs from '@/config/dynamic/dynamic.config';
import resolverSchemas from '@/config/dynamic/resolvers/schemas/resolver-schemas';
import {
type PublicDynamicConfigKeys,
type ArgsOfLoadedConfigsResolvers,
} from '@/utils/config/config.types';

const publicConfigKeys = Object.entries(dynamicConfigs)
.filter(([_, d]) => d.isPublic)
.map(([k]) => k) as PublicDynamicConfigKeys[];

const getConfigValueQueryParamsSchema = z
.object({
configKey: z.string(),
jsonArgs: z.string().optional(),
})
.transform((data, ctx) => {
const configKey = data.configKey as PublicDynamicConfigKeys;

// validate configKey
if (!publicConfigKeys.includes(configKey)) {
ctx.addIssue({
code: z.ZodIssueCode.invalid_enum_value,
options: publicConfigKeys,
received: configKey,
fatal: true,
});

return z.NEVER;
}

// parse jsonArgs
let parsedArgs;
try {
parsedArgs = data.jsonArgs
? JSON.parse(decodeURIComponent(data.jsonArgs))
: undefined;
} catch {
ctx.addIssue({ code: 'custom', message: 'Invalid JSON' });
return z.NEVER;
}

// validate jsonArgs
const configKeyForSchema = configKey as keyof typeof resolverSchemas;
let validatedArgs = parsedArgs;
if (resolverSchemas[configKeyForSchema]) {
const schema = resolverSchemas[configKey as keyof typeof resolverSchemas];
const { error, data } = schema.args.safeParse(parsedArgs);
validatedArgs = data;
if (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid jsonArgs type provided. ${error.errors[0].message}`,
fatal: true,
});
return z.NEVER;
}
}
const result: {
configKey: PublicDynamicConfigKeys;
jsonArgs: Pick<
ArgsOfLoadedConfigsResolvers,
PublicDynamicConfigKeys
>[PublicDynamicConfigKeys];
} = {
configKey,
jsonArgs: validatedArgs,
};
return result;
});

export default getConfigValueQueryParamsSchema;
13 changes: 13 additions & 0 deletions src/utils/config/__fixtures__/resolved-config-values.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { type LoadedConfigResolvedValues } from '../config.types';

const mockResolvedConfigValues: LoadedConfigResolvedValues = {
DYNAMIC: 2,
ADMIN_SECURITY_TOKEN: 'mock-secret',
CADENCE_WEB_PORT: '3000',
COMPUTED: ['mock-computed'],
COMPUTED_WITH_ARG: ['mock-arg'],
DYNAMIC_WITH_ARG: 5,
GRPC_PROTO_DIR_BASE_PATH: 'mock/path/to/grpc/proto',
GRPC_SERVICES_NAMES: 'mock-grpc-service-name',
};
export default mockResolvedConfigValues;
8 changes: 8 additions & 0 deletions src/utils/config/__mocks__/get-config-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import mockResolvedConfigValues from '../__fixtures__/resolved-config-values';
import { type LoadedConfigResolvedValues } from '../config.types';

export default jest.fn(function <K extends keyof LoadedConfigResolvedValues>(
key: K
) {
return Promise.resolve(mockResolvedConfigValues[key]);
});
10 changes: 5 additions & 5 deletions src/utils/config/__tests__/transform-configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { z } from 'zod';
import {
type InferResolverSchema,
type ConfigEnvDefinition,
type LoadedConfigs,
type ConfigSyncResolverDefinition,
type ConfigAsyncResolverDefinition,
type InferLoadedConfig,
} from '../config.types';
import transformConfigs from '../transform-configs';

Expand Down Expand Up @@ -33,7 +33,7 @@ describe('getTransformedConfigs', () => {
const result = await transformConfigs(configs, resolversSchemas);
expect(result).toEqual({
config1: 'envValue1',
} satisfies LoadedConfigs<typeof configs>);
} satisfies InferLoadedConfig<typeof configs>);
});

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

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

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

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

0 comments on commit 9fc9459

Please sign in to comment.