Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enhancement/env-parsing-with-zod #498

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});
Loading