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

@ -19,6 +19,8 @@
],
"license": "MIT",
"dependencies": {
"ajv": "^6.4.0",
"ajv-errors": "^1.0.0",
"create-react-class": "^15.6.0",
"diacritics": "^1.3.0",
"emotion": "^9.2.6",

View File

@ -1,5 +1,5 @@
import { fromJS } from 'immutable';
import { applyDefaults, validateConfig } from '../config';
import { applyDefaults } from '../config';
describe('config', () => {
describe('applyDefaults', () => {
@ -55,77 +55,4 @@ describe('config', () => {
}));
});
});
describe('validateConfig', () => {
it('should return the config if no errors', () => {
const collections = [{
name: 'posts',
folder: '_posts',
fields: [{ name: 'title', label: 'title' }],
}];
const config = fromJS({
foo: 'bar',
backend: { name: 'bar' },
media_folder: 'baz',
collections,
});
expect(
validateConfig(config)
).toEqual(config);
});
it('should throw if backend is not defined in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar' }));
}).toThrowError('Error in configuration file: A `backend` wasn\'t found. Check your config.yml file.');
});
it('should throw if backend name is not defined in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: {} }));
}).toThrowError('Error in configuration file: A `backend.name` wasn\'t found. Check your config.yml file.');
});
it('should throw if backend name is not a string in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: { } } }));
}).toThrowError('Error in configuration file: Your `backend.name` must be a string. Check your config.yml file.');
});
it('should throw if media_folder is not defined in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' } }));
}).toThrowError('Error in configuration file: A `media_folder` wasn\'t found. Check your config.yml file.');
});
it('should throw if media_folder is not a string in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: {} }));
}).toThrowError('Error in configuration file: Your `media_folder` must be a string. Check your config.yml file.');
});
it('should throw if collections is not defined in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz' }));
}).toThrowError('Error in configuration file: A `collections` wasn\'t found. Check your config.yml file.');
});
it('should throw if collections not an array in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: {} }));
}).toThrowError('Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.');
});
it('should throw if collections is an empty array in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [] }));
}).toThrowError('Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.');
});
it('should throw if collections is an array with a single null element in config', () => {
expect(() => {
validateConfig(fromJS({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz', collections: [null] }));
}).toThrowError('Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.');
});
});
});

View File

@ -1,11 +1,9 @@
import yaml from "js-yaml";
import { Map, List, fromJS } from "immutable";
import { trimStart, flow, isBoolean, get } from "lodash";
import { Map, fromJS } from "immutable";
import { trimStart, flow, get } from "lodash";
import { authenticateUser } from "Actions/auth";
import { formatByExtension, supportedFormats, frontmatterFormats } from "Formats/formats";
import { selectIdentifier } from "Reducers/collections";
import { IDENTIFIER_FIELDS } from "Constants/fieldInference";
import * as publishModes from "Constants/publishModes";
import { validateConfig } from 'Constants/configSchema';
export const CONFIG_REQUEST = "CONFIG_REQUEST";
export const CONFIG_SUCCESS = "CONFIG_SUCCESS";
@ -43,76 +41,6 @@ export function applyDefaults(config) {
});
}
function validateCollection(collection) {
const {
name,
folder,
files,
format,
extension,
frontmatter_delimiter: delimiter,
} = collection.toJS();
if (!folder && !files) {
throw new Error(`Unknown collection type for collection "${name}". Collections can be either Folder based or File based.`);
}
if (format && !supportedFormats.includes(format)) {
throw new Error(`Unknown collection format for collection "${name}". Supported formats are ${supportedFormats.join(',')}`);
}
if (!format && extension && !formatByExtension(extension)) {
// Cannot infer format from extension.
throw new Error(`Please set a format for collection "${name}". Supported formats are ${supportedFormats.join(',')}`);
}
if (delimiter && !frontmatterFormats.includes(format)) {
// Cannot set custom delimiter without explicit and proper frontmatter format declaration
throw new Error(`Please set a proper frontmatter format for collection "${name}" to use a custom delimiter. Supported frontmatter formats are yaml-frontmatter, toml-frontmatter, and json-frontmatter.`);
}
if (folder && !selectIdentifier(collection)) {
// Verify that folder-type collections have an identifier field for slug creation.
throw new Error(`Collection "${name}" must have a field that is a valid entry identifier. Supported fields are ${IDENTIFIER_FIELDS.join(', ')}.`);
}
}
export function validateConfig(config) {
if (!config.get('backend')) {
throw new Error("Error in configuration file: A `backend` wasn't found. Check your config.yml file.");
}
if (!config.getIn(['backend', 'name'])) {
throw new Error("Error in configuration file: A `backend.name` wasn't found. Check your config.yml file.");
}
if (typeof config.getIn(['backend', 'name']) !== 'string') {
throw new Error("Error in configuration file: Your `backend.name` must be a string. Check your config.yml file.");
}
if (!config.get('media_folder')) {
throw new Error("Error in configuration file: A `media_folder` wasn't found. Check your config.yml file.");
}
if (typeof config.get('media_folder') !== 'string') {
throw new Error("Error in configuration file: Your `media_folder` must be a string. Check your config.yml file.");
}
const slug_encoding = config.getIn(['slug', 'encoding'], "unicode");
if (slug_encoding !== "unicode" && slug_encoding !== "ascii") {
throw new Error("Error in configuration file: Your `slug.encoding` must be either `unicode` or `ascii`. Check your config.yml file.")
}
if (!isBoolean(config.getIn(['slug', 'clean_accents'], false))) {
throw new Error("Error in configuration file: Your `slug.clean_accents` must be a boolean. Check your config.yml file.");
}
if (!config.get('collections')) {
throw new Error("Error in configuration file: A `collections` wasn't found. Check your config.yml file.");
}
const collections = config.get('collections');
if (!List.isList(collections) || collections.isEmpty() || !collections.first()) {
throw new Error("Error in configuration file: Your `collections` must be an array with at least one element. Check your config.yml file.");
}
/**
* Validate Collections
*/
config.get('collections').forEach(validateCollection);
return config;
}
function mergePreloadedConfig(preloadedConfig, loadedConfig) {
const map = fromJS(loadedConfig) || Map();
return preloadedConfig ? preloadedConfig.mergeDeep(map) : map;
@ -190,7 +118,9 @@ export function loadConfig() {
* Merge any existing configuration so the result can be validated.
*/
const mergedConfig = mergePreloadedConfig(preloadedConfig, loadedConfig);
const config = flow(validateConfig, applyDefaults)(mergedConfig);
validateConfig(mergedConfig.toJS());
const config = applyDefaults(mergedConfig);
dispatch(configDidLoad(config));
dispatch(authenticateUser());

View File

@ -38,6 +38,16 @@ const AppMainContainer = styled.div`
margin: 0 auto;
`
const ErrorContainer = styled.div`
margin: 20px;
`
const ErrorCodeBlock = styled.pre`
margin-left: 20px;
font-size: 15px;
line-height: 1.5;
`
class App extends React.Component {
static propTypes = {
@ -53,15 +63,17 @@ class App extends React.Component {
};
static configError(config) {
return (<div>
<h1>Error loading the CMS configuration</h1>
return (
<ErrorContainer>
<h1>Error loading the CMS configuration</h1>
<div>
<p>The <code>config.yml</code> file could not be loaded or failed to parse properly.</p>
<p><strong>Error message:</strong> {config.get('error')}</p>
<p>Check your console for details.</p>
</div>
</div>);
<div>
<strong>Config Errors:</strong>
<ErrorCodeBlock>{config.get('error')}</ErrorCodeBlock>
<span>Check your config.yml file.</span>
</div>
</ErrorContainer>
);
}
componentDidMount() {

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);
}
}

View File

@ -1,4 +1,5 @@
import { List } from 'immutable';
import { get } from 'lodash';
import yamlFormatter from './yaml';
import tomlFormatter from './toml';
import jsonFormatter from './json';
@ -6,18 +7,7 @@ import { FrontmatterInfer, frontmatterJSON, frontmatterTOML, frontmatterYAML } f
export const frontmatterFormats = ['yaml-frontmatter','toml-frontmatter','json-frontmatter']
export const supportedFormats = [
'yml',
'yaml',
'toml',
'json',
'frontmatter',
'json-frontmatter',
'toml-frontmatter',
'yaml-frontmatter',
];
export const formatToExtension = format => ({
export const formatExtensions = {
yml: 'yml',
yaml: 'yml',
toml: 'toml',
@ -26,32 +16,28 @@ export const formatToExtension = format => ({
'json-frontmatter': 'md',
'toml-frontmatter': 'md',
'yaml-frontmatter': 'md',
}[format]);
};
export function formatByExtension(extension) {
return {
yml: yamlFormatter,
yaml: yamlFormatter,
toml: tomlFormatter,
json: jsonFormatter,
md: FrontmatterInfer,
markdown: FrontmatterInfer,
html: FrontmatterInfer,
}[extension];
}
export const extensionFormatters = {
yml: yamlFormatter,
yaml: yamlFormatter,
toml: tomlFormatter,
json: jsonFormatter,
md: FrontmatterInfer,
markdown: FrontmatterInfer,
html: FrontmatterInfer,
};
function formatByName(name, customDelimiter) {
return {
yml: yamlFormatter,
yaml: yamlFormatter,
toml: tomlFormatter,
json: jsonFormatter,
frontmatter: FrontmatterInfer,
'json-frontmatter': frontmatterJSON(customDelimiter),
'toml-frontmatter': frontmatterTOML(customDelimiter),
'yaml-frontmatter': frontmatterYAML(customDelimiter),
}[name];
}
const formatByName = (name, customDelimiter) => ({
yml: yamlFormatter,
yaml: yamlFormatter,
toml: tomlFormatter,
json: jsonFormatter,
frontmatter: FrontmatterInfer,
'json-frontmatter': frontmatterJSON(customDelimiter),
'toml-frontmatter': frontmatterTOML(customDelimiter),
'yaml-frontmatter': frontmatterYAML(customDelimiter),
}[name]);
export function resolveFormat(collectionOrEntity, entry) {
// Check for custom delimiter
@ -68,14 +54,14 @@ export function resolveFormat(collectionOrEntity, entry) {
const filePath = entry && entry.path;
if (filePath) {
const fileExtension = filePath.split('.').pop();
return formatByExtension(fileExtension);
return get(extensionFormatters, fileExtension);
}
// If creating a new file, and an `extension` is specified in the
// collection config, infer the format from that extension.
const extension = collectionOrEntity.get('extension');
if (extension) {
return formatByExtension(extension);
return get(extensionFormatters, extension);
}
// If no format is specified and it cannot be inferred, return the default.

View File

@ -1,10 +1,10 @@
import { List } from 'immutable';
import { escapeRegExp } from 'lodash';
import { get, escapeRegExp } from 'lodash';
import consoleError from 'Lib/consoleError';
import { CONFIG_SUCCESS } from 'Actions/config';
import { FILES, FOLDER } from 'Constants/collectionTypes';
import { INFERABLE_FIELDS, IDENTIFIER_FIELDS } from 'Constants/fieldInference';
import { formatToExtension } from 'Formats/formats';
import { formatExtensions } from 'Formats/formats';
const collections = (state = null, action) => {
switch (action.type) {
@ -30,7 +30,7 @@ const collections = (state = null, action) => {
const selectors = {
[FOLDER]: {
entryExtension(collection) {
return (collection.get('extension') || formatToExtension(collection.get('format') || 'frontmatter')).replace(/^\./, '');
return (collection.get('extension') || get(formatExtensions, (collection.get('format') || 'frontmatter'))).replace(/^\./, '');
},
fields(collection) {
return collection.get('fields');
@ -97,6 +97,7 @@ export const selectTemplateName = (collection, slug) => selectors[collection.get
export const selectIdentifier = collection => {
const fieldNames = collection.get('fields').map(field => field.get('name'));
return IDENTIFIER_FIELDS.find(id => fieldNames.find(name => name.toLowerCase().trim() === id));
// There must be a field whose `name` matches one of the IDENTIFIER_FIELDS.
};
export const selectInferedField = (collection, fieldName) => {
const inferableField = INFERABLE_FIELDS[fieldName];

View File

@ -929,6 +929,10 @@ add-stream@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa"
ajv-errors@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.0.tgz#ecf021fa108fd17dfb5e6b383f2dd233e31ffc59"
ajv-keywords@^3.1.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.2.0.tgz#e86b819c602cf8821ad637413698f1dec021847a"
@ -942,7 +946,7 @@ ajv@^5.1.0:
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.3.0"
ajv@^6.1.0:
ajv@^6.1.0, ajv@^6.4.0:
version "6.5.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.5.2.tgz#678495f9b82f7cca6be248dd92f59bff5e1f4360"
dependencies: