Skip to content

Commit

Permalink
Add support for JSON schema (#58)
Browse files Browse the repository at this point in the history
Fixes sindresorhus/electron-store#16

Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
  • Loading branch information
yaodingyd and sindresorhus committed Apr 2, 2019
1 parent bb26c4f commit 373afb9
Show file tree
Hide file tree
Showing 6 changed files with 261 additions and 1 deletion.
34 changes: 34 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,40 @@ declare namespace Conf {
@default 'nodejs'
*/
readonly projectSuffix?: string;

/**
[JSON Schema](https://json-schema.org) to validate your config data.
Under the hood, the JSON Schema validator [ajv](https://github.com/epoberezkin/ajv) is used to validate your config. We use [JSON Schema draft-07](http://json-schema.org/latest/json-schema-validation.html) and support all [validation keywords](https://github.com/epoberezkin/ajv/blob/master/KEYWORDS.md) and [formats](https://github.com/epoberezkin/ajv#formats).
You should define your schema as an object where each key is the name of your data's property and each value is a JSON schema used to validate that property. See more [here](https://json-schema.org/understanding-json-schema/reference/object.html#properties).
@example
```
import Conf = require('conf');
const schema = {
foo: {
type: 'number',
maximum: 100,
minimum: 1,
default: 50
},
bar: {
type: 'string',
format: 'url'
}
};
const config = new Conf({schema});
config.set('foo', '1');
// [Error: Config schema violation: `foo` should be number]
```
__Please note the `default` value will be overwritten by `defaults` option if set.__
*/
readonly schema?: {[key: string]: unknown};
}
}

Expand Down
38 changes: 37 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const makeDir = require('make-dir');
const pkgUp = require('pkg-up');
const envPaths = require('env-paths');
const writeFileAtomic = require('write-file-atomic');
const Ajv = require('ajv');

const plainObject = () => Object.create(null);

Expand Down Expand Up @@ -60,6 +61,24 @@ class Conf {

this._options = options;

if (options.schema) {
if (typeof options.schema !== 'object') {
throw new TypeError('The `schema` option must be an object.');
}

const ajv = new Ajv({
allErrors: true,
format: 'full',
useDefaults: true,
errorDataPath: 'property'
});
const schema = {
type: 'object',
properties: options.schema
};
this._validator = ajv.compile(schema);
}

this.events = new EventEmitter();
this.encryptionKey = options.encryptionKey;
this.serialize = options.serialize;
Expand All @@ -70,13 +89,27 @@ class Conf {

const fileStore = this.store;
const store = Object.assign(plainObject(), options.defaults, fileStore);
this._validate(store);
try {
assert.deepEqual(fileStore, store);
} catch (_) {
this.store = store;
}
}

_validate(data) {
if (!this._validator) {
return;
}

const valid = this._validator(data);
if (!valid) {
const errors = this._validator.errors.reduce((error, {dataPath, message}) =>
error + ` \`${dataPath.slice(1)}\` ${message};`, '');
throw new Error('Config schema violation:' + errors.slice(0, -1));
}
}

get(key, defaultValue) {
return dotProp.get(this.store, key, defaultValue);
}
Expand Down Expand Up @@ -166,7 +199,9 @@ class Conf {
} catch (_) {}
}

return Object.assign(plainObject(), this.deserialize(data));
data = this.deserialize(data);
this._validate(data);
return Object.assign(plainObject(), data);
} catch (error) {
if (error.code === 'ENOENT') {
// TODO: Use `fs.mkdirSync` `recursive` option when targeting Node.js 12
Expand All @@ -186,6 +221,7 @@ class Conf {
// Ensure the directory exists as it could have been deleted in the meantime
makeDir.sync(path.dirname(this.path));

this._validate(value);
let data = this.serialize(value);

if (this.encryptionKey) {
Expand Down
1 change: 1 addition & 0 deletions index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ new Conf<string>({clearInvalidConfig: false});
new Conf<string>({serialize: value => 'foo'});
new Conf<string>({deserialize: string => ({})});
new Conf<string>({projectSuffix: 'foo'});
new Conf<string>({schema: {foo: {type: 'string'}}});

conf.set('foo', 'bar');
conf.set('hello', 1);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"cache"
],
"dependencies": {
"ajv": "^6.10.0",
"dot-prop": "^4.2.0",
"env-paths": "^2.1.0",
"make-dir": "^2.1.0",
Expand Down
39 changes: 39 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ Type: `Object`

Config used if there are no existing config.

**Please note the values in `defaults` will overwrite the `default` key in `schema` option.**

#### configName

Type: `string`<br>
Expand Down Expand Up @@ -147,6 +149,43 @@ You can pass an empty string to remove the suffix.

For example, on macOS, the config file will be stored in the `~/Library/Preferences/foo-nodejs` directory, where `foo` is the `projectName`.

#### schema

type: `Object`<br>
Default: `undefined`

[JSON Schema](https://json-schema.org) to validate your config data.

Under the hood, JSON Schema validator [ajv](https://github.com/epoberezkin/ajv) is used to validate your config. We use [JSON Schema draft-07](http://json-schema.org/latest/json-schema-validation.html) and support all [validation keywords](https://github.com/epoberezkin/ajv/blob/master/KEYWORDS.md) and [formats](https://github.com/epoberezkin/ajv#formats).

You should define your schema as an object where each key is the name of your data's property and each value is a JSON schema used to validate that property. See more [here](https://json-schema.org/understanding-json-schema/reference/object.html#properties).

Example:

```js
const Conf = require('conf');

const schema = {
foo: {
type: 'number',
maximum: 100,
minimum: 1,
default: 50
},
bar: {
type: 'string',
format: 'url'
}
};

const config = new Conf({schema});

config.set('foo', '1');
// [Error: Config schema violation: `foo` should be number]
```

**Please note the `default` value will be overwritten by `defaults` option if set.**

### Instance

You can use [dot-notation](https://github.com/sindresorhus/dot-prop) in a `key` to access nested properties.
Expand Down
149 changes: 149 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -434,3 +434,152 @@ test('`clearInvalidConfig` option - valid data', t => {
conf.set('foo', 'bar');
t.deepEqual(conf.store, {foo: 'bar'});
});

test('schema - should be an object', t => {
const schema = 'object';
t.throws(() => {
new Conf({cwd: tempy.directory(), schema}); // eslint-disable-line no-new
}, 'The `schema` option must be an object.');
});

test('schema - valid set', t => {
const schema = {
foo: {
type: 'object',
properties: {
bar: {
type: 'number'
},
foobar: {
type: 'number',
maximum: 100
}
}
}
};
const conf = new Conf({cwd: tempy.directory(), schema});
t.notThrows(() => {
conf.set('foo', {bar: 1, foobar: 2});
});
});

test('schema - one violation', t => {
const schema = {
foo: {
type: 'string'
}
};
const conf = new Conf({cwd: tempy.directory(), schema});
t.throws(() => {
conf.set('foo', 1);
}, 'Config schema violation: `foo` should be string');
});

test('schema - multiple violations', t => {
const schema = {
foo: {
type: 'object',
properties: {
bar: {
type: 'number'
},
foobar: {
type: 'number',
maximum: 100
}
}
}
};
const conf = new Conf({cwd: tempy.directory(), schema});
t.throws(() => {
conf.set('foo', {bar: '1', foobar: 101});
}, 'Config schema violation: `foo.bar` should be number; `foo.foobar` should be <= 100');
});

test('schema - complex schema', t => {
const schema = {
foo: {
type: 'string',
maxLength: 3,
pattern: '[def]+'
},
bar: {
type: 'array',
uniqueItems: true,
maxItems: 3,
items: {
type: 'integer'
}
}
};
const conf = new Conf({cwd: tempy.directory(), schema});
t.throws(() => {
conf.set('foo', 'abca');
}, 'Config schema violation: `foo` should NOT be longer than 3 characters; `foo` should match pattern "[def]+"');
t.throws(() => {
conf.set('bar', [1, 1, 2, 'a']);
}, 'Config schema violation: `bar` should NOT have more than 3 items; `bar[3]` should be integer; `bar` should NOT have duplicate items (items ## 1 and 0 are identical)');
});

test('schema - invalid write to config file', t => {
const schema = {
foo: {
type: 'string'
}
};
const cwd = tempy.directory();

const conf = new Conf({cwd, schema});
fs.writeFileSync(path.join(cwd, 'config.json'), JSON.stringify({foo: 1}));
t.throws(() => {
conf.get('foo');
}, 'Config schema violation: `foo` should be string');
});

test('schema - default', t => {
const schema = {
foo: {
type: 'string',
default: 'bar'
}
};
const conf = new Conf({
cwd: tempy.directory(),
schema
});
t.is(conf.get('foo'), 'bar');
});

test('schema - Conf defaults overwrites schema default', t => {
const schema = {
foo: {
type: 'string',
default: 'bar'
}
};
const conf = new Conf({
cwd: tempy.directory(),
defaults: {
foo: 'foo'
},
schema
});
t.is(conf.get('foo'), 'foo');
});

test('schema - validate Conf default', t => {
const schema = {
foo: {
type: 'string'
}
};
t.throws(() => {
new Conf({ // eslint-disable-line no-new
cwd: tempy.directory(),
defaults: {
foo: 1
},
schema
});
}, 'Config schema violation: `foo` should be string');
});

0 comments on commit 373afb9

Please sign in to comment.