-
Notifications
You must be signed in to change notification settings - Fork 12
attempt to hoist better errors #62
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,21 +3,124 @@ | |
const jsonschema = require('jsonschema'); | ||
const links = require('./links'); | ||
const functionalConstraints = require('../functional-constraints'); | ||
const { flattenDeep, get } = require('lodash'); | ||
|
||
const ambiguousTypes = ['anyOf', 'oneOf', 'allOf']; | ||
|
||
const makeLinks = (error, makerFunc) => { | ||
if (typeof error.schema == 'string') { | ||
if (typeof error.schema === 'string') { | ||
return [makerFunc(error.schema)]; | ||
} | ||
if ( | ||
['anyOf', 'oneOf', 'allOf'].indexOf(error.name) !== -1 && | ||
ambiguousTypes.includes(error.name) && | ||
error.argument && | ||
error.argument.length | ||
) { | ||
return error.argument.map(makerFunc); | ||
// no way to know what the subschema was, so don't create links for it | ||
return error.argument | ||
.map(s => (s.includes('subschema') ? '' : makerFunc(s))) | ||
.filter(Boolean); | ||
} | ||
return []; | ||
}; | ||
|
||
const removeFirstAndLastChar = s => s.slice(1, -1); | ||
// always return a string | ||
const makePath = (path, newSegment) => | ||
(path ? [path, newSegment].join('.') : newSegment) || ''; | ||
|
||
const processBaseError = (err, path) => { | ||
const completePath = makePath(path, err.property) | ||
.replace(/\.instance\.?/g, '.') | ||
.replace(/\.instance$/, ''); | ||
|
||
const subSchemas = err.message.match(/\[subschema \d+\]/g); | ||
if (subSchemas) { | ||
subSchemas.forEach((subschema, idx) => { | ||
// err.schema is either an anonymous schema object or the name of a named schema | ||
if (typeof err.schema === 'string') { | ||
// this is basically only for FieldChoicesSchema and I'm not sure why | ||
err.message += ' Consult the docs below for valid subschemas.'; | ||
} else { | ||
// the subschemas have a type property | ||
err.message = err.message.replace( | ||
subschema, | ||
err.schema[err.name][idx].type || 'unknown' | ||
); | ||
} | ||
}); | ||
} | ||
|
||
err.property = completePath; | ||
return err; | ||
}; | ||
|
||
/** | ||
* We have a lot of `anyOf` schemas that return ambiguous errors. This recurses down the schema until it finds the errors that cause the failures replaces the ambiguity. | ||
* @param {ValidationError} validationError an individual error | ||
* @param {string} path current path in the error chain | ||
* @param {Validator} validator validator object to pass around that has all the schemas | ||
* @param {object} definition the original schema we're defining | ||
*/ | ||
const cleanError = (validationError, path, validator, definition) => { | ||
if (ambiguousTypes.includes(validationError.name)) { | ||
// flatObjectSchema requires each property to be a type. instead of recursing down, it's more valuable to say "hey, it's not of these types" | ||
if (validationError.argument.every(s => s.includes('subschema'))) { | ||
return processBaseError(validationError, path); | ||
} | ||
|
||
// Try against each of A, B, and C to take a guess as to which it's closed to | ||
// errorGroups will be an array of arrays of errors | ||
const errorGroups = validationError.argument.map((schemaName, idx) => { | ||
// this is what we'll validate against next | ||
let nextSchema; | ||
// schemaName is either "[subschema n]" or "/NamedSchema" | ||
if (schemaName.startsWith('[subschema')) { | ||
const maybeNamedSchema = validator.schemas[validationError.schema]; | ||
|
||
if (maybeNamedSchema) { | ||
nextSchema = maybeNamedSchema[validationError.name][idx]; | ||
} else { | ||
// hoist the anonymous subschema up | ||
nextSchema = validationError.schema[validationError.name][idx]; | ||
} | ||
} else { | ||
nextSchema = validator.schemas[removeFirstAndLastChar(schemaName)]; | ||
} | ||
|
||
if (validationError.instance === undefined) { | ||
// Work around a jsonschema bug: When the value being validated is | ||
// falsy, validationError.instance isn't available | ||
// See https://github.com/tdegrunt/jsonschema/issues/263 | ||
const fullPath = | ||
path.replace(/^instance\./, '') + | ||
validationError.property.replace(/^instance\./, ''); | ||
validationError.instance = get(definition, fullPath); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Throwing a hard error here blocks the dev from continuing their work, which is worse than the original behavior IMO. Maybe we want to take a milder approach here. Can we print a warning message and keep validation going, so the dev can see the validation errors at least? I found one of the reasons why {
inputFields: [''],
} To work around this bug, I suggest we do this before we do if (validationError.instance === undefined) {
// Work around a jsonschema bug: When the value being validated is a
// falsy, such as false and '', validationError.instance isn't available.
// See https://github.com/tdegrunt/jsonschema/issues/263
const fullPath = path.replace(/^instance\./, '') + validationError.property.replace(/^instance\./, '.');
validationError.instance = _.get(definition, fullPath);
} Important: You need to pass There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ha, that's why I made this change but didn't connect the dots. Great find! The reason I was throwing the error was that there should be an error here (since validation has failed) but we have no error to throw. If we don't throw, we fail to validate but there's nothing to show, which I think is worse behavior. Given that, do you think it's appropriate to throw an error? I do like pulling the instance out from above though, if we can. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking like what if they have other errors? So for example, if I do I'd have no idea what's wrong with my code if I've ever seen this. However, if we print a warning message instead of throwing an error over this, we can still see the other error: All that is to say even if validation has failed on one branch, we can still keep running validation on the other branches. Does that make sense? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah, sure. I guess in my head we could return an error instead of throwing it because we don't want to actually halt execution. good call! |
||
|
||
const res = validator.validate(validationError.instance, nextSchema); | ||
|
||
return res.errors.map(e => | ||
cleanError( | ||
e, | ||
makePath(path, validationError.property), | ||
validator, | ||
definition | ||
) | ||
); | ||
}); | ||
|
||
// find the group with the fewest errors, that's probably the most accurate | ||
// if we're goign to tweak what gets returned, this is where we'll do it | ||
// a possible improvement could be treating a longer path favorably, like the python implementation does | ||
errorGroups.sort((a, b) => a.length - b.length); | ||
return errorGroups[0]; | ||
} else { | ||
// base case | ||
return processBaseError(validationError, path); | ||
} | ||
}; | ||
|
||
const makeValidator = (mainSchema, subSchemas) => { | ||
const schemas = [mainSchema].concat(subSchemas || []); | ||
const v = new jsonschema.Validator(); | ||
|
@@ -26,11 +129,15 @@ const makeValidator = (mainSchema, subSchemas) => { | |
}); | ||
return { | ||
validate: definition => { | ||
const results = v.validate(definition, mainSchema); | ||
results.errors = results.errors.concat( | ||
const { errors, ...results } = v.validate(definition, mainSchema); | ||
const allErrors = errors.concat( | ||
functionalConstraints.run(definition, mainSchema) | ||
); | ||
results.errors = results.errors.map(error => { | ||
const cleanedErrors = flattenDeep( | ||
allErrors.map(e => cleanError(e, '', v, definition)) | ||
); | ||
|
||
results.errors = cleanedErrors.map(error => { | ||
error.codeLinks = makeLinks(error, links.makeCodeLink); | ||
error.docLinks = makeLinks(error, links.makeDocLink); | ||
return error; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,10 @@ | ||
'use strict'; | ||
|
||
require('should'); | ||
const should = require('should'); | ||
|
||
const AuthenticationSchema = require('../lib/schemas/AuthenticationSchema'); | ||
const CreateSchema = require('../lib/schemas/CreateSchema'); | ||
const TriggerSchema = require('../lib/schemas/TriggerSchema'); | ||
|
||
describe('readability', () => { | ||
it('should have decent messages for anyOf mismatches', () => { | ||
|
@@ -12,13 +13,12 @@ describe('readability', () => { | |
test: 'whateverfake!' | ||
}); | ||
results.errors.should.have.length(1); | ||
results.errors[0].stack.should.eql( | ||
'instance.test is not exactly one from </RequestSchema>,</FunctionSchema>' | ||
); | ||
results.errors[0].stack.should.eql('instance is not of a type(s) object'); | ||
should(results.errors[0].property.endsWith('instance')).be.false(); | ||
}); | ||
|
||
it('should have decent messages for minimum length not met', () => { | ||
const results = CreateSchema.validate({ | ||
const results = TriggerSchema.validate({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any reasons why this change? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Triggerschema operation is itself an |
||
key: 'recipe', | ||
noun: 'Recipe', | ||
display: { | ||
|
@@ -31,13 +31,14 @@ describe('readability', () => { | |
} | ||
}); | ||
results.errors.should.have.length(1); | ||
should(results.errors[0].property.endsWith('instance')).be.false(); | ||
results.errors[0].stack.should.eql( | ||
'instance.display.label does not meet minimum length of 2' | ||
); | ||
}); | ||
|
||
it('should have decent messages for value type mismatch', () => { | ||
let results = CreateSchema.validate({ | ||
const results = CreateSchema.validate({ | ||
key: 'recipe', | ||
noun: 'Recipe', | ||
display: { | ||
|
@@ -47,15 +48,35 @@ describe('readability', () => { | |
operation: { | ||
perform: '$func$2$f$', | ||
sample: { id: 1 }, | ||
inputFields: [false] | ||
inputFields: [123] | ||
} | ||
}); | ||
results.errors.should.have.length(1); | ||
results.errors[0].stack.should.eql( | ||
'instance.operation.inputFields[0] is not exactly one from </FieldSchema>,</FunctionSchema>' | ||
); | ||
should(results.errors[0].property.endsWith('instance')).be.false(); | ||
results.errors[0].stack.should.eql('instance is not of a type(s) object'); | ||
}); | ||
|
||
results = CreateSchema.validate({ | ||
it('should handle falsy values for objects', () => { | ||
const results = CreateSchema.validate({ | ||
key: 'recipe', | ||
noun: 'Recipe', | ||
display: { | ||
label: 'Create Recipe', | ||
description: 'Creates a new recipe.' | ||
}, | ||
operation: { | ||
perform: '$func$2$f$', | ||
sample: { id: 1 }, | ||
inputFields: [0] | ||
} | ||
}); | ||
results.errors.should.have.length(1); | ||
should(results.errors[0].property.endsWith('instance')).be.false(); | ||
results.errors[0].stack.should.eql('instance is not of a type(s) object'); | ||
}); | ||
|
||
it('should surface deep issues', () => { | ||
const results = CreateSchema.validate({ | ||
key: 'recipe', | ||
noun: 'Recipe', | ||
display: { | ||
|
@@ -69,10 +90,65 @@ describe('readability', () => { | |
} | ||
}); | ||
results.errors.should.have.length(1); | ||
// Ideally it would be the commented version, but it would require significant changes in jsonschema | ||
// results.errors[0].stack.should.eql('instance.operation.inputFields[0].default does not meet minimum length of 1'); | ||
results.errors[0].stack.should.eql( | ||
'instance.operation.inputFields[0] is not exactly one from </FieldSchema>,</FunctionSchema>' | ||
should(results.errors[0].property.endsWith('instance')).be.false(); | ||
results.errors[0].property.should.eql( | ||
'instance.operation.inputFields[0].default' | ||
); | ||
results.errors[0].message.should.eql('does not meet minimum length of 1'); | ||
}); | ||
|
||
it('should correctly surface subschema types', () => { | ||
const results = CreateSchema.validate({ | ||
key: 'recipe', | ||
noun: 'Recipe', | ||
display: { | ||
label: 'Create Recipe', | ||
description: 'Creates a new recipe.' | ||
}, | ||
operation: { | ||
perform: { | ||
url: 'https://example.com', | ||
body: 123 | ||
}, | ||
sample: { id: 1 } | ||
} | ||
}); | ||
results.errors.should.have.length(1); | ||
results.errors[0].property.should.eql('instance.operation.perform.body'); | ||
should( | ||
results.errors[0].message.includes('null,string,object,array') | ||
).be.true(); | ||
should(results.errors[0].property.endsWith('instance')).be.false(); | ||
results.errors[0].docLinks.length.should.eql(0); | ||
}); | ||
|
||
it('should be helpful for fieldChoices', () => { | ||
const results = CreateSchema.validate({ | ||
key: 'recipe', | ||
noun: 'Recipe', | ||
display: { | ||
label: 'Create Recipe', | ||
description: 'Creates a new recipe.' | ||
}, | ||
operation: { | ||
perform: '$func$2$f$', | ||
sample: { id: 1 }, | ||
inputFields: [ | ||
{ | ||
key: 'adsf', | ||
// schema says these should be strings | ||
choices: [1, 2, 3] | ||
} | ||
] | ||
} | ||
}); | ||
results.errors.should.have.length(1); | ||
results.errors[0].property.should.eql( | ||
'instance.operation.inputFields[0].choices' | ||
); | ||
should( | ||
results.errors[0].docLinks[0].endsWith('schema.md#fieldchoicesschema') | ||
).be.true(); | ||
should(results.errors[0].property.endsWith('instance')).be.false(); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
eslint was complaining about
so I bumped it. Side note, I'd love to move this to use a more standard config. Standard.js is my personal favorite, but the front-end team's, airbnb's, or others are good bets too.