improvement(validation): use config schema definition for validation (#1363)

This commit is contained in:
Caleb
2018-08-07 10:27:15 -06:00
committed by Shawn Erquhart
parent 7bcdb2053d
commit 8cc6dc78ec
9 changed files with 321 additions and 200 deletions

View File

@ -0,0 +1,84 @@
import { validateConfig } from '../configSchema';
describe('config', () => {
/**
* Suppress error logging to reduce noise during testing. Jest will still
* log test failures and associated errors as expected.
*/
beforeEach(() => {
spyOn(console, 'error')
})
describe('validateConfig', () => {
it('should not throw if no errors', () => {
const config = {
foo: 'bar',
backend: { name: 'bar' },
media_folder: 'baz',
collections: [{
name: 'posts',
label: 'Posts',
folder: '_posts',
fields: [{ name: 'title', label: 'title', widget: 'string' }],
}],
};
expect(() => {
validateConfig(config);
}).not.toThrowError();
});
it('should throw if backend is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar' });
}).toThrowError("config should have required property 'backend'");
});
it('should throw if backend name is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: {} });
}).toThrowError("'backend' should have required property 'name'");
});
it('should throw if backend name is not a string in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: { } } });
}).toThrowError("'backend.name' should be string");
});
it('should throw if media_folder is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' } });
}).toThrowError("config should have required property 'media_folder'");
});
it('should throw if media_folder is not a string in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: {} });
}).toThrowError("'media_folder' should be string");
});
it('should throw if collections is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz' });
}).toThrowError("config should have required property 'collections'");
});
it('should throw if collections not an array in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: {} });
}).toThrowError("'collections' should be array");
});
it('should throw if collections is an empty array in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [] });
}).toThrowError("'collections' should NOT have less than 1 items");
});
it('should throw if collections is an array with a single null element in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [null] });
}).toThrowError("'collections[0]' should be object");
});
});
});

View File

@ -0,0 +1,175 @@
import AJV from 'ajv';
import ajvErrors from 'ajv-errors';
import {
formatExtensions,
frontmatterFormats,
extensionFormatters,
} from "Formats/formats";
import { IDENTIFIER_FIELDS } from "Constants/fieldInference";
/**
* Config for fields in both file and folder collections.
*/
const fieldsConfig = {
type: "array",
minItems: 1,
items: {
// ------- Each field: -------
type: "object",
properties: {
name: { type: "string" },
label: { type: "string" },
widget: { type: "string" },
required: { type: "boolean" },
},
required: ["name"],
},
};
/**
* The schema had to be wrapped in a function to
* fix a circular dependency problem for WebPack,
* where the imports get resolved asyncronously.
*/
const getConfigSchema = () => ({
type: "object",
properties: {
backend: {
type: "object",
properties: { name: { type: "string", examples: ["test-repo"] } },
required: ["name"],
},
display_url: { type: "string", examples: ["https://example.com"] },
media_folder: { type: "string", examples: ["assets/uploads"] },
public_folder: { type: "string", examples: ["/uploads"] },
publish_mode: {
type: "string",
enum: ["editorial_workflow"],
examples: ["editorial_workflow"],
},
slug: {
type: "object",
properties: {
encoding: { type: "string", enum: ["unicode", "ascii"] },
clean_accents: { type: "boolean" },
},
},
collections: {
type: "array",
minItems: 1,
items: {
// ------- Each collection: -------
type: "object",
properties: {
name: { type: "string" },
label: { type: "string" },
label_singular: { type: "string" },
description: { type: "string" },
folder: { type: "string" },
files: {
type: "array",
items: {
// ------- Each file: -------
type: "object",
properties: {
name: { type: "string" },
label: { type: "string" },
label_singular: { type: "string" },
description: { type: "string" },
file: { type: "string" },
fields: fieldsConfig,
},
required: ["name", "label", "file", "fields"],
},
},
slug: { type: "string" },
create: { type: "boolean" },
editor: {
type: "object",
properties: {
preview: { type: "boolean" },
},
},
format: { type: "string", enum: Object.keys(formatExtensions) },
extension: { type: "string" },
frontmatter_delimiter: { type: "string" },
fields: fieldsConfig,
},
required: ["name", "label"],
oneOf: [{ required: ["files"] }, { required: ["folder", "fields"] }],
if: { required: ["extension"] },
then: {
// Cannot infer format from extension.
if: {
properties: {
extension: { enum: Object.keys(extensionFormatters) },
},
},
else: { required: ["format"] },
},
dependencies: {
frontmatter_delimiter: {
properties: {
format: { enum: frontmatterFormats },
},
required: ["format"],
},
folder: {
errorMessage: {
_: 'must have a field that is a valid entry identifier',
},
properties: {
fields: {
contains: {
properties: {
name: { enum: IDENTIFIER_FIELDS },
},
},
},
},
},
},
},
},
},
required: ["backend", "media_folder", "collections"],
});
class ConfigError extends Error {
constructor(errors, ...args) {
const message = errors
.map(({ message, dataPath }) => {
const dotPath = dataPath
.slice(1)
.split("/")
.map(seg => (seg.match(/^\d+$/) ? `[${seg}]` : `.${seg}`))
.join("")
.slice(1);
return `${dotPath ? `'${dotPath}'` : "config"} ${message}`;
})
.join("\n");
super(message, ...args);
this.errors = errors;
this.message = message;
}
toString() {
return this.message;
}
}
/**
* `validateConfig` is a pure function. It does not mutate
* the config that is passed in.
*/
export function validateConfig(config) {
const ajv = new AJV({ allErrors: true, jsonPointers: true });
ajvErrors(ajv);
const valid = ajv.validate(getConfigSchema(), config);
if (!valid) {
console.error('Config Errors', ajv.errors);
throw new ConfigError(ajv.errors);
}
}