Repository: fastify/env-schema Branch: main Commit: 9824690a8f88 Files: 19 Total size: 39.0 KB Directory structure: gitextract_31c2uptn/ ├── .gitattributes ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── ci.yml │ └── lock-threads.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── eslint.config.js ├── index.js ├── package.json ├── test/ │ ├── basic.test.js │ ├── custom-ajv.test.js │ ├── expand.test.js │ ├── fluent-schema.test.js │ ├── make-test.js │ └── no-global.test.js └── types/ ├── index.d.ts └── index.test-d.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ # Set default behavior to automatically convert line endings * text=auto eol=lf ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" open-pull-requests-limit: 10 - package-ecosystem: "npm" directory: "/" schedule: interval: "monthly" open-pull-requests-limit: 10 ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: - main - next - 'v*' paths-ignore: - 'docs/**' - '*.md' pull_request: paths-ignore: - 'docs/**' - '*.md' # This allows a subsequently queued workflow run to interrupt previous runs concurrency: group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" cancel-in-progress: true permissions: contents: read jobs: test: permissions: contents: write pull-requests: write uses: fastify/workflows/.github/workflows/plugins-ci.yml@v6 with: license-check: true lint: true ================================================ FILE: .github/workflows/lock-threads.yml ================================================ name: Lock Threads on: schedule: - cron: '0 0 1 * *' workflow_dispatch: concurrency: group: lock permissions: contents: read jobs: lock-threads: permissions: issues: write pull-requests: write uses: fastify/workflows/.github/workflows/lock-threads.yml@v6 ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp .cache # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* # Vim swap files *.swp # macOS files .DS_Store # Clinic .clinic # lock files bun.lockb package-lock.json pnpm-lock.yaml yarn.lock # editor files .vscode .idea #tap files .tap/ ================================================ FILE: .npmrc ================================================ ignore-scripts=true package-lock=false ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019-present The Fastify team Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # env-schema [![CI](https://github.com/fastify/env-schema/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/env-schema/actions/workflows/ci.yml) [![NPM version](https://img.shields.io/npm/v/env-schema.svg?style=flat)](https://www.npmjs.com/package/env-schema) [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) Utility to check environment variables using [JSON schema](https://json-schema.org/), [Ajv](http://npm.im/ajv), with `.env` file support using Node.js built-in `parseEnv` from `node:util`. See [supporting resources](#supporting-resources) section for helpful guides on getting started. ## Install ``` npm i env-schema ``` ## Usage ```js const envSchema = require('env-schema') const schema = { type: 'object', required: [ 'PORT' ], properties: { PORT: { type: 'number', default: 3000 } } } const config = envSchema({ schema: schema, data: data, // optional, default: process.env dotenv: true // load .env if it is there, default: false // or you can pass DotenvConfigOptions // dotenv: { // path: '/custom/path/to/.env' // } }) console.log(config) // output: { PORT: 3000 } ``` Supported `.env` file options: - `path` (string): Path to the .env file (default: '.env') - `encoding` (string): File encoding (default: 'utf8') ### Custom ajv instance Optionally, the user can supply their own ajv instance: ```js const envSchema = require('env-schema') const Ajv = require('ajv') const schema = { type: 'object', required: [ 'PORT' ], properties: { PORT: { type: 'number', default: 3000 } } } const config = envSchema({ schema: schema, data: data, dotenv: true, ajv: new Ajv({ allErrors: true, removeAdditional: true, useDefaults: true, coerceTypes: true, allowUnionTypes: true }) }) console.log(config) // output: { PORT: 3000 } ``` It is possible to enhance the default ajv instance providing the `customOptions` as a function or object parameter. When `customOptions` is an object, the provided ajv options override the default ones: ```js const config = envSchema({ schema: schema, data: data, dotenv: true, ajv: { customOptions: { coerceTypes: true } } }) ``` When `customOptions` is a function, it must return the updated ajv instance. This example shows how to use the `format` keyword in your schemas. ```js const config = envSchema({ schema: schema, data: data, dotenv: true, ajv: { customOptions (ajvInstance) { require('ajv-formats')(ajvInstance) return ajvInstance } } }) ``` ### Order of configuration loading The order of precedence for configuration data is as follows, from least significant to most: 1. Data sourced from `.env` file (when `dotenv` configuration option is set) - parsed using Node.js built-in `parseEnv` 2. Data sourced from environment variables in `process.env` 3. Data provided via the `data` configuration option ### Fluent-Schema API It is also possible to use [fluent-json-schema](http://npm.im/fluent-json-schema): ```js const envSchema = require('env-schema') const S = require('fluent-json-schema') const config = envSchema({ schema: S.object().prop('PORT', S.number().default(3000).required()), data: data, // optional, default: process.env dotenv: true, // load .env if it is there, default: false expandEnv: true, // expand environment variables like $VAR or ${VAR}, default: false }) console.log(config) // output: { PORT: 3000 } ``` **NB** Support for additional properties in the schema is disabled for this plugin, with the `additionalProperties` flag set to `false` internally. ### Custom keywords This library supports the following Ajv custom keywords: #### `separator` Type: `string` Applies to type: `string` When present, the provided schema value will be split on this value. Example: ```js const envSchema = require('env-schema') const schema = { type: 'object', required: [ 'ALLOWED_HOSTS' ], properties: { ALLOWED_HOSTS: { type: 'string', separator: ',' } } } const data = { ALLOWED_HOSTS: '127.0.0.1,0.0.0.0' } const config = envSchema({ schema: schema, data: data, // optional, default: process.env dotenv: true // load .env if it is there, default: false }) // config.ALLOWED_HOSTS => ['127.0.0.1', '0.0.0.0'] ``` The ajv keyword definition objects can be accessed through the property `keywords` on the `envSchema` function: ```js const envSchema = require('env-schema') const Ajv = require('ajv') const schema = { type: 'object', properties: { names: { type: 'string', separator: ',' } } } const config = envSchema({ schema: schema, data: data, dotenv: true, ajv: new Ajv({ allErrors: true, removeAdditional: true, useDefaults: true, coerceTypes: true, allowUnionTypes: true, keywords: [envSchema.keywords.separator] }) }) console.log(config) // output: { names: ['foo', 'bar'] } ``` ### TypeScript You can specify the type of your `config`: ```ts import { envSchema, JSONSchemaType } from 'env-schema' interface Env { PORT: number; } const schema: JSONSchemaType = { type: 'object', required: [ 'PORT' ], properties: { PORT: { type: 'number', default: 3000 } } } const config = envSchema({ schema }) ``` You can also use a `JSON Schema` library like `typebox`: ```ts import { envSchema } from 'env-schema' import { Static, Type } from 'typebox' const schema = Type.Object({ PORT: Type.Number({ default: 3000 }) }) type Schema = Static const config = envSchema({ schema }) ``` If no type is specified the `config` will have the `EnvSchemaData` type. ```ts export type EnvSchemaData = { [key: string]: unknown; } ``` ## Supporting resources The following section lists helpful reference applications, articles, guides, and other resources that demonstrate the use of env-schema in different use cases and scenarios: - A reference application using [Fastify with env-schema and dotenv](https://github.com/lirantal/fastify-dotenv-envschema-example) ## Acknowledgments Kindly sponsored by [Mia Platform](https://www.mia-platform.eu/) and [NearForm](https://nearform.com). ## License Licensed under [MIT](./LICENSE). ================================================ FILE: eslint.config.js ================================================ 'use strict' module.exports = require('neostandard')({ ignores: require('neostandard').resolveIgnoresFromGitignore(), ts: true }) ================================================ FILE: index.js ================================================ 'use strict' const Ajv = require('ajv') const { parseEnv } = require('node:util') const { readFileSync } = require('node:fs') const { resolve } = require('node:path') 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) } } function expandVariables (obj) { // Expand environment variables in the format $VAR or ${VAR} for (const key in obj) { const value = obj[key] if (typeof value === 'string') { obj[key] = value.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/gi, (match, varName) => { return obj[varName] !== undefined ? obj[varName] : match }) } } } 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 }, expandEnv: { type: ['boolean'], default: false }, ajv: { type: 'object', additionalProperties: true } } } const sharedAjvInstance = getDefaultInstance() const optsSchemaValidator = sharedAjvInstance.compile(optsSchema) function envSchema (_opts) { const opts = Object.assign({}, _opts) if (opts.schema?.[Symbol.for('fluent-schema-object')]) { opts.schema = opts.schema.valueOf() } 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 schema.additionalProperties = false let { data, dotenv, env, expandEnv } = opts if (!Array.isArray(data)) { data = [data] } let parsedEnv if (dotenv) { const dotenvOpts = typeof dotenv === 'object' ? dotenv : {} const path = dotenvOpts.path || '.env' const encoding = dotenvOpts.encoding || 'utf8' try { const envFileContent = readFileSync(resolve(path), encoding) parsedEnv = parseEnv(envFileContent) } catch (err) { // Silently ignore if file doesn't exist if (err.code !== 'ENOENT') { throw err } parsedEnv = {} } } /* istanbul ignore else */ if (env) { data.unshift(process.env) } if (parsedEnv) { data.unshift(parsedEnv) } const merge = {} data.forEach(d => Object.assign(merge, d)) if (expandEnv) { expandVariables(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 instanceof Ajv) { return ajvOpts } let ajv = defaultInstance if (typeof ajvOpts === 'object' && typeof ajvOpts.customOptions === 'function') { ajv = ajvOpts.customOptions(getDefaultInstance()) if (!(ajv instanceof Ajv)) { throw new TypeError('customOptions function must return an instance of Ajv') } } else if (typeof ajvOpts === 'object' && typeof ajvOpts.customOptions === 'object') { ajv = getDefaultInstance(ajvOpts.customOptions) } return ajv } function getDefaultInstance (overrideOpts = {}) { return new Ajv({ allErrors: true, removeAdditional: true, useDefaults: true, coerceTypes: true, allowUnionTypes: true, addUsedSchema: false, keywords: [separator], ...overrideOpts }) } envSchema.keywords = { separator } module.exports = envSchema module.exports.default = envSchema module.exports.envSchema = envSchema ================================================ FILE: package.json ================================================ { "name": "env-schema", "version": "7.0.0", "description": "Validate your env variables using Ajv with .env file support using Node.js built-in parseEnv", "main": "index.js", "type": "commonjs", "types": "types/index.d.ts", "scripts": { "lint": "eslint", "lint:fix": "eslint --fix", "test": "npm run test:unit && npm run test:typescript", "test:unit": "c8 --100 node --test", "test:typescript": "tsd" }, "repository": { "type": "git", "url": "git+https://github.com/fastify/env-schema.git" }, "keywords": [ "ajv", "env", "schema", "json", "dotenv", "validate", "extract", "parseEnv" ], "author": "Matteo Collina ", "contributors": [ { "name": "Manuel Spigolon", "email": "behemoth89@gmail.com" }, { "name": "Maksim Sinik", "url": "https://maksim.dev" }, { "name": "Aras Abbasi", "email": "aras.abbasi@gmail.com" }, { "name": "Frazer Smith", "email": "frazer.dev@icloud.com", "url": "https://github.com/fdawgs" } ], "license": "MIT", "bugs": { "url": "https://github.com/fastify/env-schema/issues" }, "homepage": "https://github.com/fastify/env-schema#readme", "funding": [ { "type": "github", "url": "https://github.com/sponsors/fastify" }, { "type": "opencollective", "url": "https://opencollective.com/fastify" } ], "dependencies": { "ajv": "^8.12.0" }, "devDependencies": { "typebox": "^1.0.81", "ajv-formats": "^3.0.1", "c8": "^11.0.0", "eslint": "^9.17.0", "fluent-json-schema": "^6.0.0", "neostandard": "^0.13.0", "tsd": "^0.33.0" } } ================================================ FILE: test/basic.test.js ================================================ 'use strict' const { test } = require('node:test') const makeTest = require('./make-test') const { join } = require('node:path') process.env.VALUE_FROM_ENV = 'pippo' const tests = [ { name: 'empty ok', schema: { type: 'object' }, data: { }, isOk: true, confExpected: {} }, { name: 'simple object - ok', schema: { type: 'object', properties: { PORT: { type: 'string' } } }, data: { PORT: '44' }, isOk: true, confExpected: { PORT: '44' } }, { name: 'simple object - ok - coerce value', schema: { type: 'object', properties: { PORT: { type: 'integer' } } }, data: { PORT: '44' }, isOk: true, confExpected: { PORT: 44 } }, { name: 'simple object - ok - remove additional properties', schema: { type: 'object', properties: { PORT: { type: 'integer' } } }, data: { PORT: '44', ANOTHER_PORT: '55' }, isOk: true, confExpected: { PORT: 44 } }, { name: 'simple object - ok - use default', schema: { type: 'object', properties: { PORT: { type: 'integer', default: 5555 } } }, data: { }, isOk: true, confExpected: { PORT: 5555 } }, { name: 'simple object - ok - required + default', schema: { type: 'object', required: ['PORT'], properties: { PORT: { type: 'integer', default: 6666 } } }, data: { }, isOk: true, confExpected: { PORT: 6666 } }, { name: 'simple object - ok - allow array', schema: { type: 'object', required: ['PORT'], properties: { PORT: { type: 'integer', default: 6666 } } }, data: [{ }], isOk: true, confExpected: { PORT: 6666 } }, { name: 'simple object - ok - merge multiple object + env', schema: { type: 'object', required: ['PORT', 'MONGODB_URL'], properties: { PORT: { type: 'integer', default: 6666 }, MONGODB_URL: { type: 'string' }, VALUE_FROM_ENV: { type: 'string' } } }, data: [{ PORT: 3333 }, { MONGODB_URL: 'mongodb://localhost/pippo' }], isOk: true, confExpected: { PORT: 3333, MONGODB_URL: 'mongodb://localhost/pippo', VALUE_FROM_ENV: 'pippo' } }, { name: 'simple object - ok - load only from env', schema: { type: 'object', required: ['VALUE_FROM_ENV'], properties: { VALUE_FROM_ENV: { type: 'string' } } }, data: undefined, isOk: true, confExpected: { VALUE_FROM_ENV: 'pippo' } }, { name: 'simple object - ok - opts override environment', schema: { type: 'object', required: ['VALUE_FROM_ENV'], properties: { VALUE_FROM_ENV: { type: 'string' } } }, data: { VALUE_FROM_ENV: 'pluto' }, isOk: true, confExpected: { VALUE_FROM_ENV: 'pluto' } }, { name: 'simple object - ok - load only from .env', schema: { type: 'object', required: ['VALUE_FROM_DOTENV'], properties: { VALUE_FROM_DOTENV: { type: 'string' } } }, data: undefined, isOk: true, dotenv: { path: join(__dirname, '.env') }, confExpected: { VALUE_FROM_DOTENV: 'look ma' } }, { name: 'simple object - KO', schema: { type: 'object', required: ['PORT'], properties: { PORT: { type: 'integer' } } }, data: { }, isOk: false, errorMessage: 'env must have required property \'PORT\'' }, { name: 'simple object - invalid data', schema: { type: 'object', required: ['PORT'], properties: { PORT: { type: 'integer' } } }, data: [], isOk: false, errorMessage: 'opts/data must NOT have fewer than 1 items, opts/data must be object, opts/data must match exactly one schema in oneOf' }, { name: 'simple object - ok - with separator', schema: { type: 'object', required: ['ALLOWED_HOSTS'], properties: { ALLOWED_HOSTS: { type: 'string', separator: ',' } } }, data: { ALLOWED_HOSTS: '127.0.0.1,0.0.0.0' }, isOk: true, confExpected: { ALLOWED_HOSTS: ['127.0.0.1', '0.0.0.0'] } }, { name: 'simple object - ok - with separator - only one value', schema: { type: 'object', required: ['ALLOWED_HOSTS'], properties: { ALLOWED_HOSTS: { type: 'string', separator: ',' } } }, data: { ALLOWED_HOSTS: '127.0.0.1' }, isOk: true, confExpected: { ALLOWED_HOSTS: ['127.0.0.1'] } }, { name: 'simple object - ok - with separator - no values', schema: { type: 'object', required: ['ALLOWED_HOSTS'], properties: { ALLOWED_HOSTS: { type: 'string', separator: ',' } } }, data: { ALLOWED_HOSTS: '' }, isOk: true, confExpected: { ALLOWED_HOSTS: [] } }, { name: 'simple object - KO - with separator', schema: { type: 'object', required: ['ALLOWED_HOSTS'], properties: { ALLOWED_HOSTS: { type: 'string', separator: ',' } } }, data: {}, isOk: false, errorMessage: 'env must have required property \'ALLOWED_HOSTS\'' }, { name: 'simple object - KO - multiple required properties', schema: { type: 'object', required: ['A', 'B', 'C'], properties: { A: { type: 'string' }, B: { type: 'string' }, C: { type: 'string' }, D: { type: 'string' } } }, data: {}, isOk: false, errorMessage: 'env must have required property \'A\', env must have required property \'B\', env must have required property \'C\'' }, { name: 'simple object - ok - dotenv with non-existent file', schema: { type: 'object', properties: { PORT: { type: 'integer', default: 3000 } } }, data: { PORT: 8080 }, dotenv: { path: join(__dirname, '.env.nonexistent') }, isOk: true, confExpected: { PORT: 8080 } }, { name: 'simple object - ok - dotenv true with no file', schema: { type: 'object', properties: { PORT: { type: 'integer', default: 3000 } } }, data: { PORT: 9090 }, dotenv: true, isOk: true, confExpected: { PORT: 9090 } }, { name: 'simple object - KO - dotenv with directory instead of file', schema: { type: 'object', properties: { PORT: { type: 'integer', default: 3000 } } }, data: { PORT: 8080 }, dotenv: { path: __dirname }, isOk: false, errorMessage: 'EISDIR: illegal operation on a directory, read' } ] tests.forEach(function (testConf) { test(testConf.name, t => { const options = { schema: testConf.schema, data: testConf.data, dotenv: testConf.dotenv, dotenvConfig: testConf.dotenvConfig } makeTest(t, options, testConf.isOk, testConf.confExpected, testConf.errorMessage) }) }) ================================================ FILE: test/custom-ajv.test.js ================================================ 'use strict' const { test } = require('node:test') const Ajv = require('ajv') const makeTest = require('./make-test') const { join } = require('node:path') process.env.VALUE_FROM_ENV = 'pippo' const tests = [ { name: 'empty ok', schema: { type: 'object' }, data: { }, isOk: true, confExpected: {} }, { name: 'simple object - ok', schema: { type: 'object', properties: { PORT: { type: 'string' } } }, data: { PORT: '44' }, isOk: true, confExpected: { PORT: '44' } }, { name: 'simple object - ok - coerce value', schema: { type: 'object', properties: { PORT: { type: 'integer' } } }, data: { PORT: '44' }, isOk: true, confExpected: { PORT: 44 } }, { name: 'simple object - ok - remove additional properties', schema: { type: 'object', properties: { PORT: { type: 'integer' } } }, data: { PORT: '44', ANOTHER_PORT: '55' }, isOk: true, confExpected: { PORT: 44 } }, { name: 'simple object - ok - use default', schema: { type: 'object', properties: { PORT: { type: 'integer', default: 5555 } } }, data: { }, isOk: true, confExpected: { PORT: 5555 } }, { name: 'simple object - ok - required + default', schema: { type: 'object', required: ['PORT'], properties: { PORT: { type: 'integer', default: 6666 } } }, data: { }, isOk: true, confExpected: { PORT: 6666 } }, { name: 'simple object - ok - allow array', schema: { type: 'object', required: ['PORT'], properties: { PORT: { type: 'integer', default: 6666 } } }, data: [{ }], isOk: true, confExpected: { PORT: 6666 } }, { name: 'simple object - ok - merge multiple object + env', schema: { type: 'object', required: ['PORT', 'MONGODB_URL'], properties: { PORT: { type: 'integer', default: 6666 }, MONGODB_URL: { type: 'string' }, VALUE_FROM_ENV: { type: 'string' } } }, data: [{ PORT: 3333 }, { MONGODB_URL: 'mongodb://localhost/pippo' }], isOk: true, confExpected: { PORT: 3333, MONGODB_URL: 'mongodb://localhost/pippo', VALUE_FROM_ENV: 'pippo' } }, { name: 'simple object - ok - load only from env', schema: { type: 'object', required: ['VALUE_FROM_ENV'], properties: { VALUE_FROM_ENV: { type: 'string' } } }, data: undefined, isOk: true, confExpected: { VALUE_FROM_ENV: 'pippo' } }, { name: 'simple object - ok - opts override environment', schema: { type: 'object', required: ['VALUE_FROM_ENV'], properties: { VALUE_FROM_ENV: { type: 'string' } } }, data: { VALUE_FROM_ENV: 'pluto' }, isOk: true, confExpected: { VALUE_FROM_ENV: 'pluto' } }, { name: 'simple object - ok - load only from .env', schema: { type: 'object', required: ['VALUE_FROM_DOTENV'], properties: { VALUE_FROM_DOTENV: { type: 'string' } } }, data: undefined, isOk: true, dotenv: { path: join(__dirname, '.env') }, confExpected: { VALUE_FROM_DOTENV: 'look ma' } }, { name: 'simple object - KO', schema: { type: 'object', required: ['PORT'], properties: { PORT: { type: 'integer' } } }, data: { }, isOk: false, errorMessage: 'env must have required property \'PORT\'' }, { name: 'simple object - invalid data', schema: { type: 'object', required: ['PORT'], properties: { PORT: { type: 'integer' } } }, data: [], isOk: false, errorMessage: 'opts/data must NOT have fewer than 1 items, opts/data must be object, opts/data must match exactly one schema in oneOf' } ] const ajv = new Ajv({ allErrors: true, removeAdditional: true, useDefaults: true, coerceTypes: true, allowUnionTypes: true }) tests.forEach(function (testConf) { test(testConf.name, t => { const options = { schema: testConf.schema, data: testConf.data, dotenv: testConf.dotenv, dotenvConfig: testConf.dotenvConfig, ajv } makeTest(t, options, testConf.isOk, testConf.confExpected, testConf.errorMessage) }) }) const noCoercionTest = { name: 'simple object - not ok - should NOT coerce value', schema: { type: 'object', properties: { PORT: { type: 'integer' } } }, data: { PORT: '44' }, isOk: false, errorMessage: 'env/PORT must be integer', confExpected: { PORT: 44 } } const strictValidator = new Ajv({ allErrors: true, removeAdditional: true, useDefaults: true, coerceTypes: false, allowUnionTypes: true }); [noCoercionTest].forEach(function (testConf) { test(testConf.name, t => { const options = { schema: testConf.schema, data: testConf.data, dotenv: testConf.dotenv, dotenvConfig: testConf.dotenvConfig, ajv: strictValidator } makeTest(t, options, testConf.isOk, testConf.confExpected, testConf.errorMessage) }) }) test('ajv enhancement', async t => { t.plan(3) const testCaseFn = { schema: { type: 'object', required: ['MONGODB_URL'], properties: { MONGODB_URL: { type: 'string', format: 'uri' } } }, data: [{ PORT: 3333 }, { MONGODB_URL: 'mongodb://localhost/pippo' }], isOk: true, confExpected: { MONGODB_URL: 'mongodb://localhost/pippo' } } const testCaseObj = { schema: { type: 'object', required: ['PORT'], properties: { PORT: { type: 'string', } } }, data: [{ PORT: 3333 }], isOk: true, confExpected: { PORT: '3333' } } await t.test('customOptions fn return', async t => { const options = { schema: testCaseFn.schema, data: testCaseFn.data, ajv: { customOptions (ajvInstance) { require('ajv-formats')(ajvInstance) return ajvInstance } } } makeTest(t, options, testCaseFn.isOk, testCaseFn.confExpected) }) await t.test('customOptions fn no return', async t => { const options = { schema: testCaseFn.schema, data: testCaseFn.data, ajv: { customOptions (_ajvInstance) { // do nothing } } } makeTest(t, options, false, undefined, 'customOptions function must return an instance of Ajv') }) await t.test('customOptions object override', async t => { const options = { schema: testCaseObj.schema, data: testCaseObj.data, ajv: { customOptions: { coerceTypes: true, } } } makeTest(t, options, testCaseObj.isOk, testCaseObj.confExpected) }) }) ================================================ FILE: test/expand.test.js ================================================ 'use strict' const { test } = require('node:test') const makeTest = require('./make-test') const { join } = require('node:path') process.env.K8S_NAMESPACE = 'pippo' process.env.K8S_CLUSTERID = 'pluto' process.env.URL = 'https://prefix.$K8S_NAMESPACE.$K8S_CLUSTERID.my.domain.com' process.env.PASSWORD = 'password' const tests = [ { name: 'simple object - ok - expandEnv', schema: { type: 'object', properties: { URL: { type: 'string' }, K8S_NAMESPACE: { type: 'string' } } }, expandEnv: true, isOk: true, confExpected: { URL: 'https://prefix.pippo.pluto.my.domain.com', K8S_NAMESPACE: 'pippo' } }, { name: 'simple object - ok - expandEnv use dotenv', schema: { type: 'object', properties: { EXPANDED_VALUE_FROM_DOTENV: { type: 'string' } } }, expandEnv: true, isOk: true, dotenv: { path: join(__dirname, '.env') }, confExpected: { EXPANDED_VALUE_FROM_DOTENV: 'the password is password!' } }, { name: 'simple object - ok - expandEnv works when passed an arbitrary new object based on process.env as data', schema: { type: 'object', properties: { URL: { type: 'string' }, K8S_NAMESPACE: { type: 'string' } } }, expandEnv: true, isOk: true, data: { ...process.env, K8S_NAMESPACE: 'hello' }, confExpected: { URL: 'https://prefix.hello.pluto.my.domain.com', K8S_NAMESPACE: 'hello' } }, { name: 'simple object - ok - expandEnv with undefined variable keeps placeholder', schema: { type: 'object', properties: { MESSAGE: { type: 'string' } } }, expandEnv: true, isOk: true, data: { MESSAGE: 'Hello $UNDEFINED_VAR world' }, confExpected: { MESSAGE: 'Hello $UNDEFINED_VAR world' } } ] tests.forEach(function (testConf) { test(testConf.name, t => { const options = { schema: testConf.schema, data: testConf.data, dotenv: testConf.dotenv, dotenvConfig: testConf.dotenvConfig, expandEnv: testConf.expandEnv } makeTest(t, options, testConf.isOk, testConf.confExpected, testConf.errorMessage) }) }) ================================================ FILE: test/fluent-schema.test.js ================================================ 'use strict' const { test } = require('node:test') if (parseInt(process.versions.node.split('.', 1)[0]) <= 8) { test.skip('not supported') } else { run() } function run () { const S = require('fluent-json-schema') const makeTest = require('./make-test') test('simple object - fluent-json-schema', t => { const options = { schema: S.object().prop('PORT', S.string()), data: { PORT: '44' } } makeTest(t, options, true, { PORT: '44' }) }) } ================================================ FILE: test/make-test.js ================================================ 'use strict' const envSchema = require('../index') function makeTest (t, options, isOk, confExpected, errorMessage) { t.plan(1) options = Object.assign({ confKey: 'config' }, options) try { const conf = envSchema(options) t.assert.deepStrictEqual(conf, confExpected) } catch (err) { if (isOk) { t.assert.fail(err) return } t.assert.strictEqual(err.message, errorMessage) } } module.exports = makeTest ================================================ FILE: test/no-global.test.js ================================================ 'use strict' const { test } = require('node:test') const envSchema = require('../index') test('no globals', t => { t.plan(2) const options = { confKey: 'secrets', data: { MONGO_URL: 'good' }, schema: { $id: 'schema:dotenv', type: 'object', required: ['MONGO_URL'], properties: { PORT: { type: 'integer', default: 3000 }, MONGO_URL: { type: 'string' } } } } { const conf = envSchema(JSON.parse(JSON.stringify(options))) t.assert.deepStrictEqual(conf, { MONGO_URL: 'good', PORT: 3000 }) } { const conf = envSchema(JSON.parse(JSON.stringify(options))) t.assert.deepStrictEqual(conf, { MONGO_URL: 'good', PORT: 3000 }) } }) ================================================ FILE: types/index.d.ts ================================================ import Ajv, { KeywordDefinition, JSONSchemaType } from 'ajv' import { AnySchema } from 'ajv/dist/core' /** * Options for loading .env files */ interface DotenvOptions { /** * Path to .env file (default: '.env') */ path?: string; /** * Encoding of .env file (default: 'utf8') */ encoding?: 'ascii' | 'utf8' | 'utf-8' | 'utf16le' | 'ucs2' | 'ucs-2' | 'base64' | 'latin1' | 'binary' | 'hex'; } type EnvSchema = typeof envSchema declare namespace envSchema { export type { JSONSchemaType } export type EnvSchemaData = { [key: string]: unknown; } export type EnvSchemaOpt = { schema?: JSONSchemaType | AnySchema; data?: [EnvSchemaData, ...EnvSchemaData[]] | EnvSchemaData; env?: boolean; dotenv?: boolean | DotenvOptions; expandEnv?: boolean; ajv?: | Ajv | { customOptions(ajvInstance: Ajv): Ajv; }; } export const keywords: { separator: KeywordDefinition } export const envSchema: EnvSchema export { envSchema as default } } declare function envSchema (_opts?: envSchema.EnvSchemaOpt): T export = envSchema ================================================ FILE: types/index.test-d.ts ================================================ import { expectError, expectType } from 'tsd' import envSchema, { EnvSchemaData, EnvSchemaOpt, keywords, envSchema as envSchemaNamed, default as envSchemaDefault, } from '..' import Ajv, { KeywordDefinition, JSONSchemaType } from 'ajv' import { Static, Type } from 'typebox' interface EnvData { PORT: number; } const schemaWithType: JSONSchemaType = { type: 'object', required: ['PORT'], properties: { PORT: { type: 'number', default: 3000, }, }, } const schemaTypebox = Type.Object({ PORT: Type.Number({ default: 3000 }), }) type SchemaTypebox = Static const data = { foo: 'bar', } expectType(envSchema()) expectType(envSchemaNamed()) expectType(envSchemaDefault()) const emptyOpt: EnvSchemaOpt = {} expectType(emptyOpt) const optWithSchemaTypebox: EnvSchemaOpt = { schema: schemaTypebox, } expectType(optWithSchemaTypebox) const optWithSchemaWithType: EnvSchemaOpt = { schema: schemaWithType, } expectType>(optWithSchemaWithType) const optWithData: EnvSchemaOpt = { data, } expectType(optWithData) expectError({ data: [], // min 1 item }) const optWithArrayData: EnvSchemaOpt = { data: [{}], } expectType(optWithArrayData) const optWithMultipleItemArrayData: EnvSchemaOpt = { data: [{}, {}], } expectType(optWithMultipleItemArrayData) const optWithDotEnvBoolean: EnvSchemaOpt = { dotenv: true, } expectType(optWithDotEnvBoolean) const optWithDotEnvOpt: EnvSchemaOpt = { dotenv: {}, } expectType(optWithDotEnvOpt) const optWithEnvExpand: EnvSchemaOpt = { expandEnv: true, } expectType(optWithEnvExpand) const optWithAjvInstance: EnvSchemaOpt = { ajv: new Ajv(), } expectType(optWithAjvInstance) expectType(envSchema.keywords.separator) const optWithAjvCustomOptions: EnvSchemaOpt = { ajv: { customOptions (_ajvInstance: Ajv): Ajv { return new Ajv() }, }, } expectType(optWithAjvCustomOptions) expectError({ ajv: { customOptions (_ajvInstance: Ajv) {}, }, }) const envSchemaWithType = envSchema({ schema: schemaWithType }) expectType(envSchemaWithType) const envSchemaTypebox = envSchema({ schema: schemaTypebox }) expectType(envSchemaTypebox) expectType(keywords.separator) expectType(envSchema.keywords.separator)