nested-env-schema
Version:
Validate & extract your env variables using nested JSON schema, Ajv and dotenvx
188 lines (153 loc) • 4.31 kB
JavaScript
;
const Ajv = require('ajv');
const dotenvx = require('@dotenvx/dotenvx');
const separator = {
keyword: 'separator',
type: 'string',
metaSchema: {
type: 'string',
description: 'value separator',
},
modifying: true,
valid: true,
errors: false,
compile:
(schema) =>
(data, { parentData: pData, parentDataProperty: pDataProperty }) => {
pData[pDataProperty] = data === '' ? [] : data.split(schema);
},
};
const optsSchema = {
type: 'object',
required: ['schema'],
properties: {
schema: { type: 'object', additionalProperties: true },
data: {
oneOf: [
{ type: 'array', items: { type: 'object' }, minItems: 1 },
{ type: 'object' },
],
default: {},
},
env: { type: 'boolean', default: true },
dotenv: { type: ['boolean', 'object'], default: false },
ajv: { type: 'object', additionalProperties: true },
},
};
const sharedAjvInstance = getDefaultInstance();
const optsSchemaValidator = sharedAjvInstance.compile(optsSchema);
function extractNested(properties, data) {
Object.entries(properties).forEach(([name, propSchema]) => {
const possibles = Object.keys(data).filter((key) =>
key.startsWith(`${name}_`),
);
const possibleObj = possibles.reduce(
(acc, key) => ({
...acc,
[key.replace(`${name}_`, '')]: data[key],
}),
{},
);
if (propSchema.type === 'object') {
data[name] = possibleObj;
if (propSchema.properties && Object.keys(propSchema.properties).length) {
propSchema.additionalProperties = false;
extractNested(propSchema.properties, data[name]);
}
} else if (propSchema.anyOf) {
data[name] = possibleObj;
propSchema.anyOf.forEach((subSchema) => {
if (subSchema.type === 'object') {
if (
subSchema.properties &&
Object.keys(subSchema.properties).length
) {
extractNested(subSchema.properties, data[name]);
}
}
});
}
});
}
function envSchema(_opts) {
const opts = Object.assign({}, _opts);
const isOptionValid = optsSchemaValidator(opts);
if (!isOptionValid) {
const error = new Error(
sharedAjvInstance.errorsText(optsSchemaValidator.errors, {
dataVar: 'opts',
}),
);
error.errors = optsSchemaValidator.errors;
throw error;
}
const { schema } = opts;
// Make sure not to capture other environment variables
schema.additionalProperties = false;
// Remove $schema keyword if present
if (schema.$schema) {
delete schema.$schema;
}
let { data, dotenv, env } = opts;
if (!Array.isArray(opts.data)) {
data = [data];
}
if (dotenv) {
dotenvx.config(Object.assign({}, dotenv));
}
/* istanbul ignore else */
if (env) {
// Treat encrypted values as empty
Object.entries(process.env).forEach(([key, value]) => {
if (value.startsWith('encrypted:')) {
delete process.env[key];
}
});
data.unshift(process.env);
}
const merge = {};
data.forEach((d) => Object.assign(merge, d));
if (schema.properties) {
extractNested(schema.properties, merge);
}
const ajv = chooseAjvInstance(sharedAjvInstance, opts.ajv);
const valid = ajv.validate(schema, merge);
if (!valid) {
const error = new Error(ajv.errorsText(ajv.errors, { dataVar: 'env' }));
error.errors = ajv.errors;
throw error;
}
return merge;
}
function chooseAjvInstance(defaultInstance, ajvOpts) {
if (!ajvOpts) {
return defaultInstance;
} else if (
typeof ajvOpts === 'object' &&
typeof ajvOpts.customOptions === 'function'
) {
const ajv = ajvOpts.customOptions(getDefaultInstance());
if (!(ajv instanceof Ajv)) {
throw new TypeError(
'customOptions function must return an instance of Ajv',
);
}
return ajv;
}
return ajvOpts;
}
function getDefaultInstance() {
return new Ajv({
allErrors: true,
removeAdditional: true,
useDefaults: true,
coerceTypes: true,
allowUnionTypes: true,
addUsedSchema: false,
keywords: [separator],
});
}
envSchema.keywords = { separator };
module.exports = envSchema;
module.exports.default = envSchema;
module.exports.envSchema = envSchema;