@kibibit/configit 1.0.0-beta.1 → 1.0.0-beta.16

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