diff --git a/README.md b/README.md index 697dfcd..cd1bb93 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ The recommended way to structure applications with this package is as follows: 'use strict' const Config = require('@logdna/env-config') -const config = new Config([ - Config.string('loglevel').default('info') -, Config.number('port').default(3000) +const config = Config.createConfig([ + Config.string('loglevel').default('info'), + Config.number('port').default(3000), ]) module.exports = config @@ -54,12 +54,50 @@ const config = require('./config.js') // This validates that we have the necessary env vars. config.validateEnvVars() -http.listen(config.get('port'), () => { - log.info('listen', config.get('port')) +// When typing `config.get()` you should see auto-complete +// for what's been configured in `config.js` and the variable +// assigned should have its type inferred; in this case a `number`. +const port = config.get('port') // inferred as number +// const port = config.port // alternatively, direct property access + +http.listen(port, () => { + log.info('listen', port) }) ``` -Under the hood, `Config` is a [``][], so use it like one. +Under the hood, `Config` is a [``][], so, for the most part, you can use it like one. + +## Auto-complete + +This package ships a `index.d.ts` file. Modern editors (VS Code, WebStorm, etc...) +automatically pick up these TypeScript declaration files and surface +**IntelliSense in plain `.js` files**. You do **not** need to convert your project +to TypeScript to enjoy auto-complete but you will need to use `createConfig` instead +of using `new Config` constructor when no TypeScript tooling is present or used by +your editor. + +### Direct Property Access + +At runtime each definition name is exposed as an enumerable getter on the `Config` instance. +This means in a Node REPL you can do the following: + +``` +> const Config = require('@logdna/env-config') +> const cfg = new Config([ Config.string('name').default('app'), Config.number('port').default(3000) ]) +> cfg.validateEnvVars() +> cfg. // press shows: name, port, get, set, ... +``` + +The `.d.ts` declaration includes an index signature so modern editors, and TypeScript enabled +editors/tooling know those dynamic properties exist. + +### Factory Helper `createConfig` + +`createConfig([...])` is a convenience wrapper returning a typed `Config` instance; identical to `new Config([...])` +but can improve inference in some editor cases. + +## Generating Docs + This package also provides a way to automatically generate documentation for the environment variables for a service. @@ -87,6 +125,16 @@ You should also add a link to this document in the `README.md` of the service. Each `input` item should be a `Definition`. See "Static Methods" below. +### `Config.createConfig(input)` + +* `input` [``][] Array of objects that represent a single rule. + +Each `input` item should be a `Definition`. See "Static Methods" below. + +If you want auto-complete and type inference features, and are using an +editor which doesn't support or isn't configured to use typescript type +definitions, use this instead of `new Config(input)`. + ### Static Methods --- @@ -312,7 +360,7 @@ is a dead code path. These two options are mutually exclusive. * `expected` [``][] The regular expression that is expected to match the discovered value * `actual` *(Any)* The value that was discovered in the environment - * `env` [``][] The name of the evironment variable that is supposed + * `env` [``][] The name of the environment variable that is supposed to hold the value (upper cased with underscores, e.g. `MY_VARIABLE`) This error is thrown if [`Config.regex()`](#configregexname) was used, @@ -324,7 +372,7 @@ but the discovered value in the environment did not match the pattern. * `name` [``][] Static value of `EnumError` * `expected` [``][] The list of acceptable values for the definition * `actual` *(Any)* The value that was discovered in the environment - * `env` [``][] The name of the evironment variable that is supposed + * `env` [``][] The name of the environment variable that is supposed to hold the value (upper cased with underscores, e.g. `MY_VARIABLE`) This error is thrown if [`Config.regex()`](#configregexname) was used, @@ -339,7 +387,7 @@ but the discovered value in the environment did not match the pattern. * `input` [``][] The the value of the environment variable after it was parsed and sanitized * `original` [``][] The original value from the environment variable * `type` [``][] The defined value type of the list property - * `env` [``][] The name of the evironment variable that is supposed + * `env` [``][] The name of the environment variable that is supposed to hold the value (upper cased with underscores, e.g. `MY_VARIABLE`) This error is thrown if [`Config.list()`](#configlistname) was used, @@ -366,10 +414,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - - - - + + + + +

Evan Lucas

💻 📖

Darin Spivey

💻 📖

Jacob Hull

🚧

Eric Satterwhite

💻

Evan Lucas

💻 📖

Darin Spivey

💻 📖

Jacob Hull

🚧

Eric Satterwhite

💻

Justin Gross

💻📖
diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..ceb13f2 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,122 @@ +// Type definitions for @logdna/env-config +// Minimal, inferred key/value typing for configuration definitions + +// Utility types to extract literal types from arrays +export type ReadonlyStringArray = readonly string[]; + +// Base definition interface (runtime class is untyped JS; this is a structural representation) +interface BaseDefinition { + _name: Name; + _type: Kind; + // Chainable common methods + required(): this; + desc(str: string): this; + description(str: string): this; + default(val: any): this; // default does not change exposed type here (runtime may still allow null) + allowEmpty(): this; + name(str: string): this; +} + +// Enum extension captures allowed values to narrow type +interface EnumCapable extends BaseDefinition { + values(vals: V): EnumCapableWithValues; +} +interface EnumCapableWithValues extends BaseDefinition { + readonly __enumValues: V[number]; + values(vals: V): this; // further calls keep same type +} + +// Regex definition +interface RegexDefinition extends BaseDefinition { + match(re: string | RegExp): this; +} + +// Number definition (min/max chaining) +interface NumberDefinition extends BaseDefinition { + min(n: number): this; + max(n: number): this; +} + +// Boolean definition (no special methods beyond base) +interface BooleanDefinition extends BaseDefinition {} + +// String definition +interface StringDefinition extends BaseDefinition {} + +// List definition (captures element type & separator) +interface ListDefinition extends BaseDefinition { + type(t: T): ListDefinitionWithType; + separator(val: string | RegExp): this; +} +interface ListDefinitionWithType extends BaseDefinition { + readonly __listType: ElemKind; + type(t: ElemKind): this; // idempotent if called again + separator(val: string | RegExp): this; +} + +type ListElementKind = 'string' | 'number' | 'boolean'; + +// Aggregate union of any definition forms +export type DefinitionAny = + | StringDefinition + | NumberDefinition + | BooleanDefinition + | RegexDefinition + | EnumCapable + | EnumCapableWithValues + | ListDefinition + | ListDefinitionWithType; + +// Infer the names from a readonly tuple of definitions +export type DefinitionNames = Defs[number]['_name']; + +// Find definition by name in tuple +export type FindDefinition = Extract; + +// Map definition kind (and its refinements) to a resulting value type. +// These reflect runtime behavior loosely (null for unset strings/lists, undefined for invalid booleans, etc.) +export type ValueOfDefinition = + D extends EnumCapableWithValues ? D['__enumValues'] | null : + D extends { _type: 'enum' } ? string | null : + D extends { _type: 'string' } ? string | null : + D extends { _type: 'number' } ? number : + D extends { _type: 'boolean' } ? boolean | undefined : + D extends { _type: 'regex' } ? string | null : + D extends ListDefinitionWithType ? ( + E extends 'string' ? string[] | null : + E extends 'number' ? number[] | null : + E extends 'boolean' ? (boolean | undefined)[] | null : any + ) : + D extends { _type: 'list' } ? any[] | null : + unknown; + +// Produce a record type for all definitions in a tuple +export type ConfigShape = { + [K in DefinitionNames]: ValueOfDefinition> +}; + +// Main Config class declaration +declare class Config extends Map, any> { + constructor(input: Defs); + // Override get/has with key autocomplete and value typing + get>(key: K): ConfigShape[K]; + has>(key: K): boolean; + toJSON(): ConfigShape; + validateEnvVars(): void; + // Index signature for direct property access (REPL getter injection at runtime) + [K in DefinitionNames]: ConfigShape[K]; + // Static builders (preserve name literal type) + static string(name: N): StringDefinition; + static number(name: N): NumberDefinition; + static boolean(name: N): BooleanDefinition; + static regex(name: N): RegexDefinition; + static enum(name: N): EnumCapable; + static list(name: N): ListDefinition; + // Factory helper for JS users + static createConfig(defs: D): Config; +} + +// Standalone factory function mirroring the static (CommonJS export augmentation) +export function createConfig(defs: D): Config; + +export = Config; diff --git a/index.js b/index.js index a58140a..33bb595 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,10 @@ 'use strict' +const util = require('util') const Definition = require('./lib/definition.js') -module.exports = class Env extends Map { +// Exported below; defined as a named class so we can augment prototype safely +class Config extends Map { constructor(input) { super() @@ -57,6 +59,8 @@ module.exports = class Env extends Map { validateEnvVars() { for (const rule of this.rules.values()) { rule.validate() + // After validation, refresh the stored value in case rule mutated (_value) + this.set(rule._name, rule._value) } } @@ -69,7 +73,50 @@ module.exports = class Env extends Map { this.set(rule._name, rule._value) this.rules.set(rule._name, rule) + + // Expose each rule as an enumerable getter property for REPL / plain JS autocomplete + if (!Object.prototype.hasOwnProperty.call(this, rule._name)) { + Object.defineProperty(this, rule._name, { + enumerable: true + , configurable: true + , get: function() { + return this.get(rule._name) + } + }) + } return this } } +// Friendly REPL / console inspection (shows plain object of key/value pairs) +Config.prototype[util.inspect.custom] = function() { + return this.toJSON() +} + +/** + * NOTE: The TypeScript declaration file (index.d.ts) supplies the rich generic + * types. These JSDoc typedefs and helper simply help JS-only consumers get autocomplete + * without enabling TypeScript compilation. + */ + +/** + * Factory helper for JS users wanting stronger inference through JSDoc. + * + * The TypeScript declaration file (index.d.ts) supplies rich generic types. + * These JSDoc typedefs and helper simply help JS-only consumers get autocomplete + * without enabling TypeScript compilation. + * + * @template {readonly any[]} Defs + * @param {Defs} defs + * @returns {Config} (Generic mapping refined by the .d.ts file) + */ +function createConfig(defs) { + return new Config(defs) +} + +// Attach factory to class & exports +Config.createConfig = createConfig + +module.exports = Config +module.exports.createConfig = createConfig + diff --git a/package.json b/package.json index 2e59597..12cddb6 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,12 @@ "version": "2.0.1", "description": "Configuration package for reading environment variables", "main": "index.js", + "types": "index.d.ts", "files": [ "bin/**/*", "lib/**/*", "index.js", + "index.d.ts", "README.md", "LICENSE" ], diff --git a/test/index.js b/test/index.js index 52fbcf9..d82c85e 100644 --- a/test/index.js +++ b/test/index.js @@ -1,46 +1,48 @@ 'use strict' +const util = require('util') const {test} = require('tap') -const Env = require('../index.js') +const Config = require('../index.js') +const {createConfig} = require('../index.js') -test('Env', async (t) => { +test('Config', async (t) => { t.test('throws if input is not an array', async (t) => { t.throws(() => { - new Env() + new Config() }, /input must be an array/) }) t.test('works with no rules', async (t) => { t.doesNotThrow(() => { - new Env([]) + new Config([]) }) }) t.test('works with multiple rules', async (t) => { - const env = new Env([ - Env.enum('loglevel').values(['info', 'error']).default('info') - , Env.boolean('pretty-print').default(false) - , Env.number('thing-count').default(5) - , Env.regex('count').match(/\d/).default(5) - , Env.string('company').default('LogDNA') - , Env.list('list-sep').type('boolean').separator(':').default('1:0') - , Env.list('list-num').type('number').default([2, 10]) - , Env.list('list-str').type('string').default('one two ') - , Env.list('list-empty').type('string') + const config = new Config([ + Config.enum('loglevel').values(['info', 'error']).default('info') + , Config.boolean('pretty-print').default(false) + , Config.number('thing-count').default(5) + , Config.regex('count').match(/\d/).default(5) + , Config.string('company').default('LogDNA') + , Config.list('list-sep').type('boolean').separator(':').default('1:0') + , Config.list('list-num').type('number').default([2, 10]) + , Config.list('list-str').type('string').default('one two ') + , Config.list('list-empty').type('string') ]) - env.validateEnvVars() - t.equal(env.get('loglevel'), 'info') - t.equal(env.get('pretty-print'), false) - t.equal(env.get('thing-count'), 5) - t.equal(env.get('count'), 5) - t.equal(env.get('company'), 'LogDNA') - t.same(env.get('list-sep'), [true, false]) - t.same(env.get('list-num'), [2, 10]) - t.same(env.get('list-str'), ['one', 'two']) - t.same(env.get('list-empty'), null) + config.validateEnvVars() + t.equal(config.get('loglevel'), 'info') + t.equal(config.get('pretty-print'), false) + t.equal(config.get('thing-count'), 5) + t.equal(config.get('count'), 5) + t.equal(config.get('company'), 'LogDNA') + t.same(config.get('list-sep'), [true, false]) + t.same(config.get('list-num'), [2, 10]) + t.same(config.get('list-str'), ['one', 'two']) + t.same(config.get('list-empty'), null) - t.same(env.toJSON(), { + t.same(config.toJSON(), { 'loglevel': 'info' , 'pretty-print': false , 'thing-count': 5 @@ -55,10 +57,68 @@ test('Env', async (t) => { t.test('throws if duplicate rules are passed', async (t) => { t.throws(() => { - new Env([ - Env.string('biscuits') - , Env.string('biscuits') + new Config([ + Config.string('biscuits') + , Config.string('biscuits') ]) }, /Rule with name "biscuits" already exists/) }) + + t.test('does not redefine property getter on second addRule path', async (t) => { + const defs = [Config.string('dup').default('one')] + const config = new Config(defs) + config.validateEnvVars() + // Manually invoke internal _addRule again with same rule to hit early duplicate check branch + t.throws(() => { + config._addRule(defs[0]) + }, /Rule with name "dup" already exists/) + }) + + t.test('re-adding rule after manual removal skips defineProperty branch', async (t) => { + const config = new Config([ + Config.string('skip-prop').default('one') + ]) + config.validateEnvVars() + // Remove rule/data but keep existing enumerable getter so hasOwnProperty => true while map lacks key + config.delete('skip-prop') + config.rules.delete('skip-prop') + const newRule = Config.string('skip-prop').default('two') + config._addRule(newRule) + t.equal(config.get('skip-prop'), 'two') + }) +}) + +test('Config additional features', async (t) => { + t.test('createConfig factory mirrors new Config()', async (t) => { + const defs = [ + Config.string('alpha').default('a') + , Config.number('beta').default(2) + ] + const configA = new Config(defs) + const configB = createConfig(defs) + configA.validateEnvVars() + configB.validateEnvVars() + t.equal(configA.get('alpha'), 'a') + t.equal(configB.get('beta'), 2) + }) + + t.test('direct property access returns same as get()', async (t) => { + const config = new Config([ + Config.string('prop-name').default('value') + , Config.boolean('flag').default(false) + ]) + config.validateEnvVars() + t.equal(config['prop-name'], 'value') + t.equal(config['prop-name'], config.get('prop-name')) + t.equal(config.flag, false) + }) + + t.test('inspect custom shows plain object', async (t) => { + const config = new Config([ + Config.string('inspect-me').default('ok') + ]) + config.validateEnvVars() + const out = util.inspect(config) + t.match(out, /'inspect-me': 'ok'/) + }) })