@kibibit/configit 1.0.0-beta.2 → 1.0.0-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,69 @@
1
- import { join } from 'path';
1
+ import { join, relative } from 'path';
2
2
 
3
3
  import { classToPlain } from 'class-transformer';
4
4
  import { validateSync } from 'class-validator';
5
+ import { cyan, red } from 'colors';
5
6
  import findRoot from 'find-root';
6
7
  import {
8
+ ensureDirSync,
7
9
  pathExistsSync,
8
10
  readdirSync,
9
11
  readJSONSync,
10
- writeJson,
11
- writeJSONSync
12
- } from 'fs-extra';
13
- import { camelCase, chain, get } from 'lodash';
14
- import nconf from 'nconf';
12
+ writeFileSync,
13
+ writeJSONSync } from 'fs-extra';
14
+ import { stringify as hjsonStringify } from 'hjson';
15
+ import { camelCase, chain, keys, mapValues, startCase, times } from 'lodash';
16
+ import nconf, { IFormat, IFormats } from 'nconf';
17
+ import nconfYamlFormat from 'nconf-yaml';
18
+ import YAML from 'yaml';
19
+
20
+ import * as nconfJsoncFormat from '@kibibit/nconf-jsonc';
15
21
 
16
22
  import { ConfigValidationError } from './config.errors';
17
- import { Config } from './config.model';
23
+ import { BaseConfig } from './config.model';
24
+ import { getEnvironment, setEnvironment } from './environment.service';
25
+
26
+ type INconfKibibitFormats = IFormats & {
27
+ yaml: nconfYamlFormat;
28
+ jsonc: IFormat;
29
+ };
30
+
31
+ const nconfFormats = nconf.formats as INconfKibibitFormats;
32
+ nconfFormats.yaml = nconfYamlFormat;
33
+ nconfFormats.jsonc = nconfJsoncFormat;
18
34
 
19
35
  export interface IConfigServiceOptions {
20
36
  convertToCamelCase?: boolean;
37
+ fileFormat?: EFileFormats;
38
+ sharedConfig?: TClass<BaseConfig>[];
39
+ skipSchema?: boolean;
40
+ schemaFolderName?: string;
41
+ showOverrides?: boolean;
42
+ configFolderRelativePath?: string;
43
+ encryptConfig?: {
44
+ algorithm: string;
45
+ secret: string;
46
+ };
21
47
  }
22
48
 
23
- const environment = get(process, 'env.NODE_ENV', 'development');
49
+ export enum EFileFormats {
50
+ json = 'json',
51
+ yaml = 'yaml',
52
+ jsonc = 'jsonc',
53
+ hjson = 'hjson'
54
+ }
55
+
56
+ export interface IWriteConfigToFileOptions {
57
+ fileFormat: EFileFormats;
58
+ excludeSchema?: boolean;
59
+ objectWrapper?: string;
60
+ outputFolder?: string;
61
+ }
24
62
 
25
63
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
64
  let configService: ConfigService<any>;
27
65
 
28
- type TClass<T> = new (partial: Partial<T>) => T;
66
+ type TClass<T> = (new (partial: Partial<T>) => T);
29
67
 
30
68
  /**
31
69
  * This is a **Forced Singleton**.
@@ -33,16 +71,15 @@ type TClass<T> = new (partial: Partial<T>) => T;
33
71
  * another ConfigService, you'll always get the
34
72
  * first one.
35
73
  */
36
- export class ConfigService<T extends Config> {
37
- private readonly mode: string = environment;
74
+ export class ConfigService<T extends BaseConfig> {
75
+ private fileExtension: EFileFormats;
76
+ readonly mode: string = getEnvironment();
38
77
  readonly options: IConfigServiceOptions;
39
78
  readonly config?: T;
40
79
  readonly genericClass?: TClass<T>;
41
80
  readonly fileName?: string;
42
- readonly jsonSchemaFullname?: string;
43
- readonly defaultConfigFilePath?: string;
44
81
  readonly configFileName: string = '';
45
- readonly configFilePath?: string;
82
+ readonly configFileFullPath?: string;
46
83
  readonly configFileRoot?: string;
47
84
  readonly appRoot: string;
48
85
 
@@ -51,77 +88,305 @@ export class ConfigService<T extends Config> {
51
88
  passedConfig?: Partial<T>,
52
89
  options: IConfigServiceOptions = {}
53
90
  ) {
54
- this.options = options;
55
- this.appRoot = this.findRoot();
56
91
  if (!passedConfig && configService) { return configService; }
57
92
 
93
+ setEnvironment(passedConfig?.NODE_ENV || getEnvironment());
94
+ this.mode = getEnvironment();
95
+
96
+ this.options = {
97
+ sharedConfig: [],
98
+ fileFormat: EFileFormats.json,
99
+ convertToCamelCase: false,
100
+ schemaFolderName: '.schemas',
101
+ skipSchema: false,
102
+ showOverrides: false,
103
+ ...options
104
+ };
105
+ this.appRoot = this.findRoot();
58
106
  this.genericClass = givenClass;
59
- this.fileName = chain(this.genericClass.name)
60
- .replace(/Config$/i, '')
61
- .kebabCase()
62
- .value();
63
- this.jsonSchemaFullname = `.${ this.fileName }.env.schema.json`;
107
+ this.fileExtension = this.options.fileFormat || EFileFormats.json;
108
+ this.config = this.createConfigInstance(this.genericClass, {}) as T;
109
+ this.configFileName = this.config.getFileName(this.fileExtension);
110
+ this.configFileRoot = this.findConfigRoot();
111
+ this.configFileFullPath = join(
112
+ this.configFileRoot,
113
+ this.configFileName
114
+ );
115
+
116
+ this.initializeNconf();
64
117
 
65
- this.configFileName = `${ this.fileName }.${ environment }.env.json`;
118
+ const config = passedConfig || nconf.get();
66
119
 
67
- this.configFileRoot = this.findConfigRoot() || this.appRoot;
120
+ const pathDoesNotExist = pathExistsSync(this.configFileFullPath) === false;
121
+ if (pathDoesNotExist && (config.saveToFile || config.init)) {
122
+ console.log(cyan('Initializing Configuration File'));
123
+ this.config = this.createConfigInstance(this.genericClass, {}) as T;
124
+ this.writeConfigToFile();
125
+ this.writeSchema();
126
+ console.log(cyan('EXITING'));
127
+ process.exit(0);
128
+ return;
129
+ }
68
130
 
69
- this.defaultConfigFilePath = join(this.configFileRoot, 'defaults.env.json');
70
- this.configFilePath = join(
71
- this.configFileRoot,
72
- `${ this.fileName }.${ environment }.env.json`
131
+ if (!this.options.skipSchema) {
132
+ this.writeSchema();
133
+ }
134
+
135
+ const envConfig = this.validateInput(config);
136
+ if (!envConfig) { return; }
137
+ envConfig.NODE_ENV = this.mode;
138
+ this.config = this.createConfigInstance(this.genericClass, envConfig as T) as T;
139
+
140
+ if (config.saveToFile || config.init) {
141
+ if (config.convert) {
142
+ console.log(cyan('Converting Configuration File'));
143
+ }
144
+ const fileFormat = config.convert ? config.convert : this.fileExtension;
145
+ const objectWrapper = config.wrapper;
146
+ this.writeConfigToFile({ fileFormat, objectWrapper });
147
+ console.log(cyan('EXITING'));
148
+ process.exit(0);
149
+ return;
150
+ }
151
+
152
+ configService = this;
153
+ }
154
+
155
+ toPlainObject() {
156
+ // hope this works now!
157
+ return classToPlain(new this.genericClass(this.config));
158
+ }
159
+
160
+ writeConfigToFile(
161
+ {
162
+ fileFormat,
163
+ excludeSchema,
164
+ objectWrapper,
165
+ outputFolder
166
+ }: IWriteConfigToFileOptions = {
167
+ fileFormat: this.options.fileFormat,
168
+ excludeSchema: false
169
+ }) {
170
+ const fileExtension = fileFormat;
171
+ const configFileName = this.config.getFileName(fileExtension);
172
+ const configFileFullPath = join(
173
+ outputFolder || this.configFileRoot,
174
+ configFileName
73
175
  );
176
+ const plainConfig = classToPlain(this.config);
177
+ const relativePathToSchema = relative(
178
+ outputFolder || this.configFileRoot,
179
+ join(this.appRoot, `/${ this.options.schemaFolderName }/${ this.config.getSchemaFileName() }`)
180
+ );
181
+ if (!excludeSchema) {
182
+ plainConfig.$schema = relativePathToSchema;
183
+ }
184
+ const orderedKeys = this.orderObjectKeys(plainConfig);
185
+
186
+ if (fileFormat === 'yaml') {
187
+ const yamlValues = chain(orderedKeys)
188
+ .omit([ '$schema' ])
189
+ // eslint-disable-next-line no-undefined
190
+ .omitBy((value) => value === undefined)
191
+ .value();
192
+
193
+ const output = objectWrapper ?
194
+ { [objectWrapper]: yamlValues } :
195
+ yamlValues;
196
+ const yamlString = keys(yamlValues).length > 0 ? YAML.stringify(output) : '';
197
+ writeFileSync(
198
+ configFileFullPath,
199
+ [
200
+ excludeSchema ? '' : [
201
+ '# yaml-language-server: $schema=',
202
+ relativePathToSchema,
203
+ '\n'
204
+ ].join(''),
205
+ yamlString
206
+ ].join('')
207
+ );
208
+ return;
209
+ }
210
+
211
+ const output = objectWrapper ?
212
+ { [objectWrapper]: orderedKeys } :
213
+ orderedKeys;
214
+
215
+ if (fileFormat === 'hjson') {
216
+ writeFileSync(configFileFullPath, hjsonStringify(output, {
217
+ quotes: 'min',
218
+ space: 2
219
+ }));
220
+
221
+ return;
222
+ }
223
+
224
+ writeJSONSync(configFileFullPath, output, { spaces: 2 });
74
225
 
226
+ for (const sharedConfig of this.options.sharedConfig) {
227
+ this.writeSharedConfigToFile(sharedConfig);
228
+ }
229
+ }
230
+
231
+ private createConfigInstance(genericClass: TClass<BaseConfig>, data) {
232
+ const configInstance = new genericClass(data);
233
+ configInstance.setName(genericClass);
234
+
235
+ return configInstance;
236
+ }
237
+
238
+ private initializeNconf() {
75
239
  nconf
76
240
  .argv({
77
241
  parseValues: true
78
242
  })
79
243
  .env({
244
+ separator: '__',
80
245
  parseValues: true,
81
246
  transform: this.options.convertToCamelCase ?
82
247
  transformToCamelCase :
83
248
  null
84
- })
85
- .file('defaults', { file: this.defaultConfigFilePath })
86
- .file('environment', { file: this.configFilePath });
249
+ });
87
250
 
88
- const config = passedConfig || nconf.get();
89
- const envConfig = this.validateInput(config);
251
+ const nconfFileOptions: nconf.IFileOptions = {
252
+ format: nconfFormats[this.options.fileFormat]
253
+ };
90
254
 
91
- this.config = new this.genericClass(envConfig as T);
255
+ if (this.options.encryptConfig) {
256
+ nconfFileOptions.secure = {
257
+ secret: this.options.encryptConfig.secret,
258
+ alg: this.options.encryptConfig.algorithm
259
+ };
260
+ }
92
261
 
93
- if (config.saveToFile || config.init) {
94
- const plainConfig = classToPlain(this.config);
95
- plainConfig.$schema = `./${ this.jsonSchemaFullname }`;
96
- const orderedKeys = chain(plainConfig)
97
- .keys()
98
- .sort()
99
- .without('NODE_ENV')
100
- .reduce((obj: { [key: string]: string }, key) => {
101
- obj[key] = plainConfig[key];
102
- return obj;
103
- }, {})
104
- // .omitBy((value, key) => key.startsWith('$'))
105
- .value();
262
+ try {
263
+ nconf
264
+ .file('environment', {
265
+ file: this.configFileFullPath,
266
+ ...nconfFileOptions
267
+ });
268
+ } catch (error) {
269
+ console.error(red(error.message));
270
+ process.exit(1);
271
+ }
106
272
 
107
- console.log(orderedKeys);
273
+ for (const sharedConfig of this.options.sharedConfig) {
274
+ const sharedConfigInstance = this.createConfigInstance(sharedConfig, {});
275
+ const sharedConfigFullPath = join(
276
+ this.configFileRoot,
277
+ sharedConfigInstance.getFileName(this.fileExtension, true),
278
+ );
279
+ try {
280
+ nconf.file(sharedConfigInstance.name, {
281
+ file: sharedConfigFullPath,
282
+ ...nconfFileOptions
283
+ });
284
+ } catch (error) {
285
+ console.error(red(error.message));
286
+ process.exit(1);
287
+ }
288
+ }
289
+ }
108
290
 
109
- writeJson(this.configFilePath, orderedKeys, { spaces: 2 });
291
+ private writeSchema() {
292
+ ensureDirSync(join(this.configFileRoot, '/', this.options.schemaFolderName));
293
+ const sharedConfigsSchemas = [];
294
+ for (const sharedConfig of this.options.sharedConfig) {
295
+ const sharedConfigSchema = this.writeSharedSchema(sharedConfig);
296
+ sharedConfigsSchemas.push(sharedConfigSchema);
110
297
  }
111
298
 
112
299
  const schema = this.config.toJsonSchema();
113
300
  const schemaFullPath = join(
114
301
  this.configFileRoot,
115
302
  '/',
116
- this.jsonSchemaFullname
303
+ this.options.schemaFolderName,
304
+ '/',
305
+ this.config.getSchemaFileName()
117
306
  );
118
- writeJSONSync(schemaFullPath, schema);
119
307
 
120
- configService = this;
308
+ let sharedConfigsProperties = {};
309
+ for (const sharedConfigSchema of sharedConfigsSchemas) {
310
+ mapValues(
311
+ sharedConfigSchema.properties,
312
+ (value) => value.description = `(OVERRIDE SHARED CONFIG)\n${ value.description }`
313
+ );
314
+ sharedConfigsProperties = {
315
+ ...sharedConfigsProperties,
316
+ ...sharedConfigSchema.properties
317
+ };
318
+ }
319
+
320
+ if (this.options.showOverrides) {
321
+ schema.properties = {
322
+ ...this.orderObjectKeys(schema.properties),
323
+ ...this.orderObjectKeys(sharedConfigsProperties)
324
+ };
325
+ }
326
+
327
+ writeJSONSync(schemaFullPath, schema, { spaces: 2 });
121
328
  }
122
329
 
123
- toPlainObject() {
124
- return classToPlain(this);
330
+ private orderObjectKeys(given: { [key: string]: any }) {
331
+ return chain(given)
332
+ .keys()
333
+ .sort()
334
+ .reduce((obj: { [key: string]: any }, key) => {
335
+ obj[key] = given[key];
336
+ return obj;
337
+ }, {})
338
+ .value();
339
+ }
340
+
341
+ private writeSharedSchema(configClass: TClass<BaseConfig>) {
342
+ const config = this.createConfigInstance(configClass, {});
343
+ const schema = config.toJsonSchema();
344
+ const schemaFullPath = join(
345
+ this.configFileRoot,
346
+ '/',
347
+ this.options.schemaFolderName,
348
+ '/',
349
+ config.getSchemaFileName()
350
+ );
351
+ writeJSONSync(schemaFullPath, schema, { spaces: 2 });
352
+
353
+ return schema;
354
+ }
355
+
356
+ private writeSharedConfigToFile(configClass: TClass<BaseConfig>) {
357
+ const config = this.createConfigInstance(configClass, this.config);
358
+ const plainConfig = classToPlain(config);
359
+ const relativePathToSchema = relative(
360
+ this.configFileRoot,
361
+ join(this.appRoot, `/${ this.options.schemaFolderName }/${ config.getSchemaFileName() }`)
362
+ );
363
+ plainConfig.$schema = relativePathToSchema;
364
+ const sharedConfigFullPath = join(
365
+ this.configFileRoot,
366
+ config.getFileName(this.fileExtension, true)
367
+ );
368
+
369
+ const orderedKeys = this.orderObjectKeys(plainConfig);
370
+
371
+ if (this.options.fileFormat === 'yaml') {
372
+ const yamlValues = chain(orderedKeys)
373
+ .omit([ '$schema' ])
374
+ // eslint-disable-next-line no-undefined
375
+ .omitBy((value) => value === undefined)
376
+ .value();
377
+ const yamlString = keys(yamlValues).length > 0 ? YAML.stringify(yamlValues) : '';
378
+ writeFileSync(
379
+ sharedConfigFullPath,
380
+ [
381
+ '# yaml-language-server: $schema=',
382
+ relativePathToSchema,
383
+ `\n${ yamlString }`
384
+ ].join('')
385
+ );
386
+ return;
387
+ }
388
+
389
+ writeJSONSync(sharedConfigFullPath, orderedKeys, { spaces: 2 });
125
390
  }
126
391
 
127
392
  private findRoot() {
@@ -141,6 +406,12 @@ export class ConfigService<T extends Config> {
141
406
  }
142
407
 
143
408
  private findConfigRoot() {
409
+ if (this.options.configFolderRelativePath) {
410
+ const fullPath = join(this.appRoot, this.options.configFolderRelativePath);
411
+ ensureDirSync(fullPath);
412
+
413
+ return fullPath;
414
+ }
144
415
  try {
145
416
  return findRoot(process.cwd(), (dir) => {
146
417
  const fileNames = readdirSync(dir);
@@ -163,10 +434,65 @@ export class ConfigService<T extends Config> {
163
434
  const configInstance = new this.genericClass(envConfig);
164
435
  const validationErrors = validateSync(configInstance);
165
436
 
437
+ let fullConfig = {};
438
+ let shouldExitProcess = false;
439
+ for (const sharedConfig of this.options.sharedConfig) {
440
+ const validationResult = this.validateSharedInput(envConfig, sharedConfig);
441
+ shouldExitProcess = shouldExitProcess || validationResult.error;
442
+ fullConfig = {
443
+ ...fullConfig,
444
+ ...validationResult.configInstance
445
+ };
446
+ }
447
+
448
+ if (validationErrors.length > 0) {
449
+ const validationError = new ConfigValidationError(validationErrors);
450
+ const errorMessageTitle = `${ startCase(this.config.name) } Configuration Errors`;
451
+ const titleBar = this.generateTerminalTitleBar(errorMessageTitle);
452
+ console.error(titleBar, validationError.message);
453
+
454
+ shouldExitProcess = shouldExitProcess || validationErrors.length > 0;
455
+ }
456
+
457
+ if (shouldExitProcess) {
458
+ process.exit(1);
459
+ return;
460
+ }
461
+
462
+ return {
463
+ ...fullConfig,
464
+ ...classToPlain(configInstance) as Partial<T>
465
+ };
466
+ }
467
+
468
+ private validateSharedInput(
469
+ envConfig: unknown,
470
+ configClass: TClass<BaseConfig>
471
+ ) {
472
+ const configInstance = this.createConfigInstance(configClass, envConfig);
473
+ const validationErrors = validateSync(configInstance);
474
+
475
+ let error = null;
166
476
  if (validationErrors.length > 0) {
167
- throw new ConfigValidationError(validationErrors);
477
+ const validationError = new ConfigValidationError(validationErrors);
478
+ const errorMessageTitle = `${ startCase(configInstance.name) } Shared Configuration Errors`;
479
+ const titleBar = this.generateTerminalTitleBar(errorMessageTitle);
480
+ error = { titleBar, message: validationError.message };
481
+ console.error(titleBar, validationError.message);
168
482
  }
169
- return classToPlain(configInstance) as Partial<T>;
483
+ return {
484
+ configInstance: classToPlain(configInstance),
485
+ error
486
+ };
487
+ }
488
+
489
+ private generateTerminalTitleBar(title: string) {
490
+ const titleBar = red(times(title.length + 4, () => '=').join(''));
491
+ return [
492
+ titleBar,
493
+ red('= ') + title + red(' ='),
494
+ titleBar
495
+ ].join('\n');
170
496
  }
171
497
  }
172
498
 
@@ -0,0 +1,13 @@
1
+ import { get } from 'lodash';
2
+
3
+ const realEnvironment = get(process, 'env.NODE_ENV', 'development');
4
+
5
+ let environment: string;
6
+
7
+ export function setEnvironment(givenEnvironment: string) {
8
+ environment = givenEnvironment;
9
+ }
10
+
11
+ export function getEnvironment() {
12
+ return environment || realEnvironment;
13
+ }
package/src/index.ts CHANGED
@@ -2,3 +2,4 @@ export * from './config.service';
2
2
  export * from './config.errors';
3
3
  export * from './config.model';
4
4
  export * from './json-schema.validator';
5
+ export * from './environment.service';
@@ -1,10 +1,16 @@
1
+ import { Exclude, Expose } from 'class-transformer';
1
2
  import {
3
+ Validate,
2
4
  ValidatorConstraint,
3
5
  ValidatorConstraintInterface
4
6
  } from 'class-validator';
5
7
 
8
+ export interface IConfigVariableOptions {
9
+ exclude?: boolean;
10
+ }
11
+
6
12
  @ValidatorConstraint({ name: 'JsonSchema', async: false })
7
- export class JsonSchema implements ValidatorConstraintInterface {
13
+ class JsonSchema implements ValidatorConstraintInterface {
8
14
  validate() {
9
15
  return true;
10
16
  }
@@ -13,3 +19,25 @@ export class JsonSchema implements ValidatorConstraintInterface {
13
19
  return '';
14
20
  }
15
21
  }
22
+
23
+ export function Configuration(): ClassDecorator {
24
+ const exposeFn = Exclude();
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ return function(target: any) {
27
+ exposeFn(target);
28
+ };
29
+ }
30
+
31
+ export function ConfigVariable(
32
+ description: string | string[] = '',
33
+ options: IConfigVariableOptions = {}
34
+ ): PropertyDecorator {
35
+ description = Array.isArray(description) ? description : [ description ];
36
+ const exposeFn = options.exclude ? Exclude() : Expose();
37
+ const typeFn = Validate(JsonSchema, description);
38
+
39
+ return function(target: unknown, key: string) {
40
+ typeFn(target, key);
41
+ exposeFn(target, key);
42
+ };
43
+ }
@@ -0,0 +1,33 @@
1
+ import { IsBoolean, IsEnum, IsOptional } from 'class-validator';
2
+ import { values } from 'lodash';
3
+
4
+ import { BaseConfig, Configuration, ConfigVariable } from './';
5
+
6
+ export enum ToppingEnum {
7
+ Cheese = 'cheese',
8
+ Pepperoni = 'pepperoni',
9
+ Sausage = 'sausage'
10
+ }
11
+
12
+ @Configuration()
13
+ export class PizzaConfig extends BaseConfig {
14
+ @IsOptional()
15
+ @IsEnum(values(ToppingEnum), {
16
+ each: true,
17
+ message: `Topping must be one of: ${ values(ToppingEnum).join(', ') }`
18
+ })
19
+ @ConfigVariable('optional toppings for the pizza')
20
+ public toppings: ToppingEnum[];
21
+
22
+ constructor(partial?: Partial<PizzaConfig>) {
23
+ super(partial);
24
+ }
25
+ }
26
+
27
+ @Configuration()
28
+ export class ToppingsConfig extends BaseConfig {
29
+ @IsOptional()
30
+ @IsBoolean()
31
+ @ConfigVariable('Should meat be included in the toppings options')
32
+ INCLUDE_MEAT;
33
+ }