Skip to content

Commit

Permalink
Merge pull request #498 from highcharts/enhancement/env-parsing-with-zod
Browse files Browse the repository at this point in the history
enhancement/env-parsing-with-zod
  • Loading branch information
PaulDalek authored Apr 3, 2024
2 parents bdcbfe9 + 91eb6ed commit 7c278e4
Show file tree
Hide file tree
Showing 9 changed files with 204 additions and 32 deletions.
6 changes: 2 additions & 4 deletions lib/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,15 @@ See LICENSE file in root for details.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';

import dotenv from 'dotenv';
import { HttpsProxyAgent } from 'https-proxy-agent';

import { fetch } from './fetch.js';
import { log } from './logger.js';
import { __dirname } from './utils.js';
import { envs } from './envs.js';

import ExportError from './errors/ExportError.js';

dotenv.config();

const cache = {
cdnURL: 'https://code.highcharts.com/',
activeManifest: {},
Expand Down Expand Up @@ -129,7 +127,7 @@ export const fetchAndProcessScript = async (
const requestOptions = proxyAgent
? {
agent: proxyAgent,
timeout: +process.env['PROXY_SERVER_TIMEOUT'] || 5000
timeout: envs.PROXY_SERVER_TIMEOUT
}
: {};

Expand Down
20 changes: 3 additions & 17 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from './schemas/config.js';
import { log, logWithStack } from './logger.js';
import { deepCopy, isObject, printUsage, toBoolean } from './utils.js';
import { envs } from './envs.js';

let generalOptions = {};

Expand Down Expand Up @@ -311,7 +312,6 @@ function updateDefaultConfig(configObj, customObj = {}, propChain = '') {
Object.keys(configObj).forEach((key) => {
const entry = configObj[key];
const customValue = customObj && customObj[key];
let numEnvVal;

if (typeof entry.value === 'undefined') {
updateDefaultConfig(entry, customValue, `${propChain}.${key}`);
Expand All @@ -322,22 +322,8 @@ function updateDefaultConfig(configObj, customObj = {}, propChain = '') {
}

// If a value from an env variable exists, it take precedence
if (entry.envLink) {
// Load the env var
if (entry.type === 'boolean') {
entry.value = toBoolean(
[process.env[entry.envLink], entry.value].find(
(el) => el || el === 'false'
)
);
} else if (entry.type === 'number') {
numEnvVal = +process.env[entry.envLink];
entry.value = numEnvVal >= 0 ? numEnvVal : entry.value;
} else if (entry.type.indexOf(']') >= 0 && process.env[entry.envLink]) {
entry.value = process.env[entry.envLink].split(',');
} else {
entry.value = process.env[entry.envLink] || entry.value;
}
if (entry.envLink in envs) {
entry.value = envs[entry.envLink];
}
}
});
Expand Down
132 changes: 132 additions & 0 deletions lib/envs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* @fileoverview
* This file is responsible for parsing the environment variables with the 'zod' library.
* The parsed environment variables are then exported to be used in the application as "envs".
* We should not use process.env directly in the application as these would not be parsed properly.
*
* The environment variables are parsed and validated only once when the application starts.
* We should write a custom validator or a transformer for each of the options.
*
* For envs not defined in config.js with defaults, we also include default values here (PROXY_...).
*/

import { z } from 'zod';
import dotenv from 'dotenv';
dotenv.config();

// Object with custom validators and transformers, to avoid repetition in the Config object
const v = {
boolean: () =>
z
.enum(['true', 'false'])
.transform((value) => value === 'true')
.optional(),
array: () =>
z
.string()
.transform((val) => val.split(',').map((v) => v.trim()))
.optional()
};

export const Config = z.object({
// highcharts
HIGHCHARTS_VERSION: z
.string()
.refine((value) => /^(latest|\d+(\.\d+){0,2})$/.test(value), {
message:
"HIGHCHARTS_VERSION must be 'latest', a major version, or in the form XX.YY.ZZ"
})
.optional(), // todo: create an array of available Highcharts versions
HIGHCHARTS_CDN_URL: z
.string()
.trim()
.refine((val) => val.startsWith('https://') || val.startsWith('http://'), {
message:
'Invalid value for HIGHCHARTS_CDN_URL. It should start with http:// or https://.'
})
.optional(),
HIGHCHARTS_CORE_SCRIPTS: v.array(),
HIGHCHARTS_MODULES: v.array(),
HIGHCHARTS_INDICATORS: v.array(),
HIGHCHARTS_FORCE_FETCH: v.boolean(),
HIGHCHARTS_CACHE_PATH: z.string().optional(),
HIGHCHARTS_ADMIN_TOKEN: z.string().optional(),

// export
EXPORT_TYPE: z.enum(['jpeg', 'png', 'pdf', 'svg']).optional(),
EXPORT_CONSTR: z
.string()
.refine(
(val) =>
['chart', 'stockChart', 'mapChart', 'ganttChart'].includes(val || ''),
{ message: 'Invalid value for EXPORT_CONSTR. ' }
)
.optional(),
EXPORT_DEFAULT_HEIGHT: z.coerce.number().positive().optional(),
EXPORT_DEFAULT_WIDTH: z.coerce.number().positive().optional(),
EXPORT_DEFAULT_SCALE: z.coerce.number().positive().optional(),
EXPORT_RASTERIZATION_TIMEOUT: z.coerce.number().positive().optional(),

// custom
CUSTOM_LOGIC_ALLOW_CODE_EXECUTION: v.boolean(),
CUSTOM_LOGIC_ALLOW_FILEL_RESOURCES: v.boolean(),

// server-related
SERVER_ENABLE: v.boolean(),
SERVER_HOST: z.string().optional(),
SERVER_PORT: z.coerce.number().optional(),
SERVER_BENCHMARKING: v.boolean(),
SERVER_SSL_ENABLE: v.boolean(),
SERVER_SSL_FORCE: v.boolean(),
SERVER_SSL_PORT: z.coerce.number().optional(),
SERVER_SSL_CERT_PATH: z.string().optional(),
SERVER_RATE_LIMITING_ENABLE: v.boolean(),
SERVER_RATE_LIMITING_MAX_REQUESTS: z.coerce.number().optional(),
SERVER_RATE_LIMITING_WINDOW: z.coerce.number().optional(),
SERVER_RATE_LIMITING_DELAY: z.coerce.number().optional(),
SERVER_RATE_LIMITING_TRUST_PROXY: v.boolean(),
SERVER_RATE_LIMITING_SKIP_KEY: z.string().optional(),
SERVER_RATE_LIMITING_SKIP_TOKEN: z.string().optional(),

// pool
POOL_MIN_WORKERS: z.coerce.number().optional(),
POOL_MAX_WORKERS: z.coerce.number().optional(),
POOL_WORK_LIMIT: z.coerce.number().optional(),
POOL_ACQUIRE_TIMEOUT: z.coerce.number().optional(),
POOL_CREATE_TIMEOUT: z.coerce.number().optional(),
POOL_DESTROY_TIMEOUT: z.coerce.number().optional(),
POOL_IDLE_TIMEOUT: z.coerce.number().optional(),
POOL_CREATE_RETRY_INTERVAL: z.coerce.number().optional(),
POOL_REAPER_INTERVAL: z.coerce.number().optional(),
POOL_BENCHMARKING: v.boolean(),
POOL_LISTEN_TO_PROCESS_EXITS: v.boolean(),

// logger
LOGGING_LEVEL: z.coerce
.number()
.optional()
.refine((val) => (val || 4) >= 0 && (val || 4) <= 4, {
message:
'Invalid value for LOGGING_LEVEL. We only accept 0, 1, 2, 3, 4 as logging levels.'
}),
LOGGING_FILE: z.string().optional(),
LOGGING_DEST: z.string().optional(),

// ui
UI_ENABLE: v.boolean(),
UI_ROUTE: z.string().optional(),

// other
OTHER_NO_LOGO: v.boolean(),
NODE_ENV: z
.enum(['development', 'production', 'test'])
.optional()
.default('production'),

// proxy (! NOT INCLUDED IN CONFIG.JS !)
PROXY_SERVER_TIMEOUT: z.coerce.number().positive().optional().default(5000),
PROXY_SERVER_HOST: z.string().optional().default('localhost'),
PROXY_SERVER_PORT: z.coerce.number().positive().optional().default(8080)
});

export const envs = Config.parse(process.env);
5 changes: 0 additions & 5 deletions lib/schemas/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ See LICENSE file in root for details.
*******************************************************************************/

// Load .env into environment variables
import dotenv from 'dotenv';

dotenv.config();

// This is the configuration object with all options and their default values,
// also from the .env file if one exists
export const defaultConfig = {
Expand Down
3 changes: 2 additions & 1 deletion lib/server/error.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { logWithStack } from '../logger.js';
import { envs } from '../envs.js';

/**
* Middleware for logging errors with stack trace and handling error response.
Expand All @@ -13,7 +14,7 @@ const logErrorMiddleware = (error, req, res, next) => {
logWithStack(1, error);

// Delete the stack for the environment other than the development
if (process.env.NODE_ENV !== 'development') {
if (envs.NODE_ENV !== 'development') {
delete error.stack;
}

Expand Down
4 changes: 2 additions & 2 deletions lib/server/routes/change_hc_version.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ See LICENSE file in root for details.
*******************************************************************************/

import cache from '../../cache.js';

import HttpError from '../../errors/HttpError.js';
import { envs } from '../../envs.js';

/**
* Adds the POST /change_hc_version/:newVersion route that can be utilized to modify
Expand All @@ -29,7 +29,7 @@ export default (app) =>
'/version/change/:newVersion',
async (request, response, next) => {
try {
const adminToken = process.env.HIGHCHARTS_ADMIN_TOKEN;
const adminToken = envs.HIGHCHARTS_ADMIN_TOKEN;

// Check the existence of the token
if (!adminToken || !adminToken.length) {
Expand Down
5 changes: 3 additions & 2 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
"prompts": "^2.4.2",
"puppeteer": "^22.4.0",
"tarn": "^3.0.2",
"uuid": "^9.0.1"
"uuid": "^9.0.1",
"zod": "^3.22.4"
},
"engines": {
"node": ">=18.0.0"
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/envs.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Config } from '../../lib/envs';

describe('Environment variables should be correctly parsed', () => {
test('HIGHCHARTS_VERSION accepts latests and not unrelated strings', () => {
const env = { HIGHCHARTS_VERSION: 'string-other-than-latest' };
expect(() => Config.parse(env)).toThrow();

env.HIGHCHARTS_VERSION = 'latest';
expect(Config.parse(env).HIGHCHARTS_VERSION).toEqual('latest');
});

test('HIGHCHARTS_VERSION accepts proper version strings like XX.YY.ZZ', () => {
const env = { HIGHCHARTS_VERSION: '11' };
expect(Config.parse(env).HIGHCHARTS_VERSION).toEqual('11');

env.HIGHCHARTS_VERSION = '11.0.0';
expect(Config.parse(env).HIGHCHARTS_VERSION).toEqual('11.0.0');

env.HIGHCHARTS_VERSION = '9.1';
expect(Config.parse(env).HIGHCHARTS_VERSION).toEqual('9.1');

env.HIGHCHARTS_VERSION = '11a.2.0';
expect(() => Config.parse(env)).toThrow();
});

test('HIGHCHARTS_CDN_URL should start with http:// or https://', () => {
const env = { HIGHCHARTS_CDN_URL: 'http://example.com' };
expect(Config.parse(env).HIGHCHARTS_CDN_URL).toEqual('http://example.com');

env.HIGHCHARTS_CDN_URL = 'https://example.com';
expect(Config.parse(env).HIGHCHARTS_CDN_URL).toEqual('https://example.com');

env.HIGHCHARTS_CDN_URL = 'example.com';
expect(() => Config.parse(env)).toThrow();
});

test('CORE_SCRIPTS, MODULES, INDICATORS should be arrays', () => {
const env = {
HIGHCHARTS_CORE_SCRIPTS: 'core1, core2',
HIGHCHARTS_MODULES: 'module1, module2',
HIGHCHARTS_INDICATORS: 'indicator1, indicator2'
};

const parsed = Config.parse(env);

expect(parsed.HIGHCHARTS_CORE_SCRIPTS).toEqual(['core1', 'core2']);
expect(parsed.HIGHCHARTS_MODULES).toEqual(['module1', 'module2']);
expect(parsed.HIGHCHARTS_INDICATORS).toEqual(['indicator1', 'indicator2']);
});

test('HIGHCHARTS_FORCE_FETCH should be a boolean', () => {
const env = { HIGHCHARTS_FORCE_FETCH: 'true' };
expect(Config.parse(env).HIGHCHARTS_FORCE_FETCH).toEqual(true);

env.HIGHCHARTS_FORCE_FETCH = 'false';
expect(Config.parse(env).HIGHCHARTS_FORCE_FETCH).toEqual(false);
});
});

0 comments on commit 7c278e4

Please sign in to comment.