Skip to content

Commit

Permalink
Merge pull request #78 from openkfw/77-refactor-env-config
Browse files Browse the repository at this point in the history
fix: fix env variable validation
  • Loading branch information
andrea-smiesna authored Oct 3, 2024
2 parents dcbc741 + cd5946b commit 23f36fa
Show file tree
Hide file tree
Showing 6 changed files with 591 additions and 309 deletions.
113 changes: 57 additions & 56 deletions app/config/client.js
Original file line number Diff line number Diff line change
@@ -1,65 +1,66 @@
/* eslint-disable @typescript-eslint/no-var-requires */

const { z } = require('zod');
const { someOrAllNotSet, formatErrors } = require('./helper');
const { formatErrors } = require('./helper');
const { env } = require('next-runtime-env');
const { createEnvConfig, createZodSchemaFromEnvConfig } = require('./envConfig');

const Config = z
.object({
STAGE: z.enum(['development', 'test', 'build', 'production']).default('development'),
NEXT_PUBLIC_STRAPI_GRAPHQL_ENDPOINT: z
.string({
errorMap: () => ({ message: 'NEXT_PUBLIC_STRAPI_GRAPHQL_ENDPOINT must be set!' }),
})
.default(''),
NEXT_PUBLIC_STRAPI_ENDPOINT: z
.string({
errorMap: () => ({ message: 'NEXT_PUBLIC_STRAPI_ENDPOINT must be set!' }),
})
.default(''),
NEXT_PUBLIC_VAPID_PUBLIC_KEY: z
.string({
errorMap: () => ({ message: 'NEXT_PUBLIC_VAPID_PUBLIC_KEY must be set!' }),
})
.default(''),
NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING: z
.string({
errorMap: () => ({ message: 'NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING must be set!' }),
})
.default(''),
NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY: z
.string({
errorMap: () => ({ message: 'NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY must be set!' }),
})
.default(''),
NEXT_PUBLIC_BUILDTIMESTAMP: z
.string({
errorMap: () => ({ message: 'NEXT_PUBLIC_BUILDTIMESTAMP must be set!' }),
})
.default(''),
NEXT_PUBLIC_CI_COMMIT_SHA: z
.string({
errorMap: () => ({ message: 'NEXT_PUBLIC_CI_COMMIT_SHA must be set!' }),
})
.default(''),
})
.superRefine((values, ctx) => {
const { NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING, NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY, STAGE } = values;
if (STAGE !== 'build') {
// The checking of the counterparts is done in the server config to limit the possibility of cross-imports.
// Assignment to default as
const appInsights = [NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING, NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY];
if (someOrAllNotSet(appInsights)) {
ctx.addIssue({
message: 'All Application Insights variables are required to enable Azure Application Insights.',
code: z.ZodIssueCode.custom,
path: ['NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING', 'NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY'],
});
}
}
});
const clientEnvConfig = createEnvConfig({
variables: {
// DO NOT remove the stage environment variable from this config
STAGE: {
defaultRule: z.enum(['development', 'test', 'build', 'production', 'lint']).default('development'),
required: true,
},

const clientConfig = Config.safeParse({
// Strapi
NEXT_PUBLIC_STRAPI_GRAPHQL_ENDPOINT: {
defaultRule: z.string().default(''),
required: true,
stages: [],
},
NEXT_PUBLIC_STRAPI_ENDPOINT: {
defaultRule: z.string().default(''),
required: true,
stages: [],
},

// Push notifications
NEXT_PUBLIC_VAPID_PUBLIC_KEY: {
defaultRule: z.string().default(''),
required: true,
stages: [],
},

// Application insights
NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING: {
defaultRule: z.string().optional(),
},
NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY: {
defaultRule: z.string().optional(),
},

// Version info
NEXT_PUBLIC_BUILDTIMESTAMP: {
defaultRule: z.string().optional(),
},
NEXT_PUBLIC_CI_COMMIT_SHA: {
defaultRule: z.string().optional(),
},
},
groups: [
{
variables: ['NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING', 'NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY'],
mode: 'none_or_all',
stages: ['development', 'test', 'production'],
errorMessage: 'All Application Insights variables are required to enable Azure Application Insights',
},
],
});

const schema = createZodSchemaFromEnvConfig(clientEnvConfig);

const clientConfig = schema.safeParse({
NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING: env('NEXT_PUBLIC_APP_INSIGHTS_CONNECTION_STRING'),
NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY: env('NEXT_PUBLIC_APP_INSIGHTS_INSTRUMENTATION_KEY'),
NEXT_PUBLIC_VAPID_PUBLIC_KEY: env('NEXT_PUBLIC_VAPID_PUBLIC_KEY'),
Expand Down
38 changes: 38 additions & 0 deletions app/config/envConfig.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { z } from 'zod';

type EnvConfig<T extends EnvVariablesConfiguration> = {
variables: T;
groups: EnvVariableGroupConfiguration<T>[];
};

// Environment variable specific configuration
type EnvVariablesConfiguration = {
[key: string]: {
defaultRule: z.ZodTypeAny;
required?: boolean;
stages?: ('development' | 'test' | 'build' | 'production' | 'lint')[];
regex?: RegExp;
};
};

// Configurations for a collection of environment variables
type EnvVariableGroupConfiguration<T> = {
variables: (keyof T)[];
mode: 'none_or_all' | 'at_least_one';
stages?: ('development' | 'test' | 'build' | 'production' | 'lint')[];
errorMessage: string;
};

// Zod schema that is created based on a environment config
type ZodSchemaType<TVariables extends EnvVariablesConfiguration> = z.ZodObject<{
[K in keyof TVariables]: TVariables[K]['defaultRule'];
}>;

export declare const createEnvConfig: <T extends EnvVariablesConfiguration>(props: {
variables: T;
groups: EnvConfig<T>['groups'];
}) => EnvConfig<T>;

export declare const createZodSchemaFromEnvConfig: <T extends EnvVariablesConfiguration>(
envConfig: EnvConfig<T>,
) => ReturnType<ZodSchemaType<T>['superRefine']>;
116 changes: 116 additions & 0 deletions app/config/envConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/* eslint-disable @typescript-eslint/no-var-requires */

const { z } = require('zod');

// Returns an environment config
const createEnvConfig = (props) => {
return { groups: props.groups, variables: props.variables };
};

// Creates a zod schema (z.Object({...})) based on an environment configuration
const createZodSchemaFromEnvConfig = (config) => {
const zodSchema = {};

Object.keys(config.variables).map((variable) => {
const key = variable;
const variableConfig = config.variables[key];
const defaultRule = variableConfig.defaultRule;
zodSchema[variable] = defaultRule;
});

return z.object(zodSchema);
};

// Adds stage (build/lint/...) specific environment variable validation to a zod schema
const addVariableValidation = (schema, envConfig) => {
const valueExists = (value) => (value?.toString().trim().length ?? 0) > 0;

return schema.superRefine((values, ctx) => {
const configKeys = Object.keys(envConfig.variables);
const currentStage = values.STAGE; // lint/build/production/...

// Validate single environment variables one-by-one
configKeys.forEach((configKey) => {
const variableConfig = envConfig.variables[configKey];

// Check if env variable should be checked at this stage
const stagesApplies = variableConfig.stages?.some((stage) => stage === currentStage) ?? true;

if (!stagesApplies) {
return;
}

const value = values[configKey];
const valueIsSet = valueExists(value);

// Optional env variables may be omitted
if (!variableConfig.required && !valueIsSet) {
return;
}

// Check that required env variables are set
if (!valueIsSet) {
ctx.addIssue({
message: `Environment variable '${String(configKey)}' is required, but not set`,
code: z.ZodIssueCode.custom,
fatal: variableConfig.required,
path: [configKey],
});
return;
}

// Check env variable against regex if configured
if (!variableConfig.regex) {
return;
}

const matchesRegex = variableConfig.regex.test(value);

if (!matchesRegex) {
ctx.addIssue({
message: `Environment variable '${String(configKey)}' has an invalid format`,
code: z.ZodIssueCode.custom,
path: [configKey],
});
}
});

// Validate env variable groups
envConfig.groups.forEach((group) => {
// Check if env variable group should be checked at this stage
const stageApplies = group.stages?.some((stage) => stage === currentStage) ?? true;

if (!stageApplies) {
return;
}

const groupValues = group.variables.map((variable) => values[variable]);
const noneAreSet = groupValues.every((value) => !valueExists(value));
const variableNames = group.variables.map((envVariable) => envVariable);

if (group.mode === 'at_least_one' && noneAreSet) {
ctx.addIssue({ message: group.errorMessage, code: z.ZodIssueCode.custom, path: variableNames });
return;
}

const someAreSet = !noneAreSet;
const someAreNotSet = groupValues.some((value) => !valueExists(value));

if (group.mode === 'none_or_all' && someAreSet && someAreNotSet) {
ctx.addIssue({ message: group.errorMessage, code: z.ZodIssueCode.custom, path: variableNames });
return;
}
});
});
};

const createValidatingZodSchemaFromEnvConfig = (envConfig) => {
const zodSchema = createZodSchemaFromEnvConfig(envConfig);
const validatingZodSchema = addVariableValidation(zodSchema, envConfig);
return validatingZodSchema;
};

module.exports = {
createEnvConfig,
createZodSchemaFromEnvConfig: createValidatingZodSchemaFromEnvConfig,
};
Loading

0 comments on commit 23f36fa

Please sign in to comment.