Module:Util/schema/validate: Difference between revisions
Jump to navigation
Jump to search
PhantomCaleb (talk | contribs) No edit summary |
PhantomCaleb (talk | contribs) No edit summary |
||
Line 1: | Line 1: | ||
-- I apologize for how big a mess this is. | |||
local utilsError = require("Module:UtilsError") | local utilsError = require("Module:UtilsError") | ||
Line 111: | Line 111: | ||
end | end | ||
local function | local function luaType(schemaType) | ||
if schemaType == TYPES.array | |||
or schemaType == TYPES.record | |||
local | or schemaType == TYPES.map | ||
then | |||
return "table" | |||
end | |||
return schemaType | |||
end | |||
local function errorCollector(errorTbl, value, dataName, dataPath, quiet, isKey) | |||
return function(validator) | |||
local errorMessages = validator(value, dataName, dataPath, isKey, { | |||
quiet = quiet, | |||
stackTrace = false, | |||
}) | |||
if type(errorMessages) ~= "table" then --errMsg can either be a single message, or an array of messages (as is the case with enum) | |||
errorMessages = {errorMessages} | |||
end | |||
for _, errMsg in ipairs(errorMessages) do | |||
table.insert(errorTbl, { | |||
path = dataName .. utilsTable.printPath(dataPath), | |||
msg = errMsg, | |||
}) | |||
end | |||
end | |||
end | |||
local function validatePrimitive(schemaNode, value, dataName, dataPath, quiet, isKey) | |||
local errors = {} | |||
local addIfError = errorCollector(errors, value, dataName, dataPath, quiet, isKey) | |||
local validatorOptions = { | |||
quiet = quiet, | |||
stackTrace = false, | |||
} | |||
if schemaNode.type and schemaNode.type ~= "any" then | |||
local expectedType = luaType(schemaNode.type) | |||
addIfError(utilsValidate.type(expectedType)) | |||
end | |||
if schemaNode.required then | |||
addIfError(utilsValidate.required) | |||
end | |||
if schemaNode.deprecated then | |||
addIfError(utilsValidate.deprecated) | |||
end | |||
if schemaNode.enum then | |||
addIfError(utilsValidate.enum(schemaNode.enum)) | |||
end | |||
return errors | |||
end | |||
local function logSubschemaErrors(subschemaErrors, path) | |||
for schemaKey, schemaErrors in pairs(subschemaErrors) do | |||
local subpath = utilsTable.concat(path or {}, '["' .. schemaKey .. '"]') | |||
local indent = string.rep(":", #subpath) | |||
for _, err in pairs(schemaErrors) do | |||
local msg = string.format("<code>%s</code>: %s", utilsTable.printPath(subpath), err.msg) | |||
utilsError.warn(indent .. msg, { traceBack = false }) | |||
if err.errors then | |||
logSubschemaErrors(err.errors, subpath) | |||
end | |||
end | |||
end | end | ||
end | |||
local function validateSubschemas(subschemas, data, dataName, dataPath, schemaNode, parentSchema) | |||
local errors = {} | |||
local valids = {} | |||
for k, subschema in pairs(subschemas) do | |||
local key = subschema._ref and string.gsub(subschema._ref, "#/definitions/", "") or k | |||
if parentSchema and parentSchema.allOf then | |||
local commonProps = {} | |||
for _, v in ipairs(parentSchema.allOf) do | |||
commonProps = utilsTable.concat(commonProps, v.properties) | |||
end | |||
schemaNode.allOfProps = commonProps | |||
end | |||
local err = doValidate(subschema, data, dataName, dataPath, schemaNode, true) | |||
if #err > 0 then | |||
errors[key] = err | |||
else | |||
table.insert(valids, key) | |||
end | |||
end | end | ||
return errors, valids | |||
end | end | ||
function | function doValidate(schemaNode, data, dataName, dataPath, parentSchema, quiet) | ||
dataPath = dataPath or {} | dataPath = dataPath or {} | ||
local value = extract(data, dataPath) | local value = extract(data, dataPath) | ||
Line 133: | Line 202: | ||
schemaNode = resolveReference(schemaNode) | schemaNode = resolveReference(schemaNode) | ||
local errors = | local errors = validatePrimitive(schemaNode, value, dataName, dataPath, quiet) | ||
if #errors > 0 then | if #errors > 0 then | ||
return errors | return errors | ||
Line 139: | Line 208: | ||
if schemaNode.allOf and value then | if schemaNode.allOf and value then | ||
local subschemaErrors = | local subschemaErrors = validateSubschemas(schemaNode.allOf, data, dataName, dataPath, schemaNode) | ||
local invalidSubschemas = utilsTable.keys(subschemaErrors) | local invalidSubschemas = utilsTable.keys(subschemaErrors) | ||
if #invalidSubschemas > 0 then | if #invalidSubschemas > 0 then | ||
Line 147: | Line 216: | ||
if not quiet then | if not quiet then | ||
utilsError.warn(msg, { traceBack = false }) | utilsError.warn(msg, { traceBack = false }) | ||
logSubschemaErrors(subschemaErrors) | |||
end | end | ||
table.insert(errors, { | table.insert(errors, { | ||
Line 157: | Line 226: | ||
end | end | ||
if schemaNode.oneOf and value then | if schemaNode.oneOf and value then | ||
local subschemaErrors, validSubschemas = | local subschemaErrors, validSubschemas = validateSubschemas(schemaNode.oneOf, data, dataName, dataPath, schemaNode, parentSchema) | ||
local invalidSubschemas = utilsTable.keys(subschemaErrors) | local invalidSubschemas = utilsTable.keys(subschemaErrors) | ||
if #validSubschemas == 0 then | if #validSubschemas == 0 then | ||
Line 163: | Line 232: | ||
if not quiet then | if not quiet then | ||
utilsError.warn(msg, { traceBack = false }) | utilsError.warn(msg, { traceBack = false }) | ||
logSubschemaErrors(subschemaErrors) | |||
end | end | ||
table.insert(errors, { | table.insert(errors, { | ||
Line 188: | Line 257: | ||
for _, propSchema in pairs(schemaNode.properties) do | for _, propSchema in pairs(schemaNode.properties) do | ||
local keyPath = utilsTable.concat(dataPath, propSchema.name) | local keyPath = utilsTable.concat(dataPath, propSchema.name) | ||
errors = utilsTable.concat(errors, | errors = utilsTable.concat(errors, doValidate(propSchema, data, dataName, keyPath, schemaNode, quiet)) | ||
end | end | ||
if not schemaNode.additionalProperties and not (parentSchema and parentSchema.allOf) then | if not schemaNode.additionalProperties and not (parentSchema and parentSchema.allOf) then | ||
Line 225: | Line 294: | ||
for i, item in ipairs(value) do | for i, item in ipairs(value) do | ||
local itemPath = utilsTable.concat(dataPath, i) | local itemPath = utilsTable.concat(dataPath, i) | ||
errors = utilsTable.concat(errors, | errors = utilsTable.concat(errors, doValidate(schemaNode.items, data, dataName, itemPath, schemaNode, quiet)) | ||
end | end | ||
end | end | ||
Line 231: | Line 300: | ||
for k, v in pairs(value) do | for k, v in pairs(value) do | ||
local keyPath = utilsTable.concat(dataPath, string.format('["%s"]', k)) | local keyPath = utilsTable.concat(dataPath, string.format('["%s"]', k)) | ||
errors = utilsTable.concat(errors, | errors = utilsTable.concat(errors, validatePrimitive(schemaNode.keys, k, dataName, dataPath, quiet, true)) | ||
errors = utilsTable.concat(errors, | errors = utilsTable.concat(errors, doValidate(schemaNode.values, data, dataName, keyPath, schemaNode, quiet)) | ||
end | end | ||
end | end | ||
Line 239: | Line 308: | ||
end | end | ||
function | local function validate(schema, schemaName, data, dataName) | ||
-- first validate the schema itself | |||
collectReferences(metaSchema) | |||
local schemaErrors = doValidate(metaSchema.values, schema, schemaName) | |||
if #schemaErrors > 0 then | |||
utilsError.warn(string.format("Schema <code>%s</code> is invalid.", schemaName)) | |||
end | end | ||
-- then validate the data | |||
collectReferences(schema) | |||
local errors = doValidate(schema, data, dataName) | |||
if #errors > 0 then | |||
utilsError.warn(string.format("<code>%s</code> is invalid according to schema <code>%s</code>", dataName, schemaName)) | |||
return errors | |||
end | end | ||
end | end | ||
return validate | return validate |
Revision as of 20:47, 12 May 2024
Lua error in package.lua at line 80: module 'Module:Util/schemas/validate/Documentation/Spec' not found.
-- I apologize for how big a mess this is.
local utilsError = require("Module:UtilsError")
local utilsTable = require("Module:UtilsTable")
local utilsValidate = require("Module:UtilsValidate")
local util = {
markup = {
code = require("Module:Util/markup/code")
},
strings = {
trim = require("Module:Util/strings/trim")
}
}
local metaSchema = require("Module:Util/schemas/validate/Documentation/Spec").Schemas()
local SYMBOLS = {
optional = "[%s]",
required = "%s!",
default = "%s=%s",
array = "{%s}",
map = "<%s, %s>",
oneOf = "%s|%s",
allOf = "%s&%s",
combinationGroup = "(%s)",
}
local TYPES = {
oneOf = "oneOf",
array = "array",
record = "record",
map = "map"
}
-- Extracts a value from nested tables given a path to the value as an array of successive keys
local function extract(tbl, path)
local result = tbl
for i, key in ipairs(path) do
result = result[key] or result[util.strings.trim(key, '%[%]"')]
end
return result
end
local function walkSchema(fn, schemaNode, path)
path = path or {}
local continue = fn(schemaNode, path)
if continue == false then
return
end
if schemaNode.items then
walkSchema(fn, schemaNode.items, utilsTable.concat(path, "items"))
end
if schemaNode.keys then
walkSchema(fn, schemaNode.keys, utilsTable.concat(path, "keys"))
end
if schemaNode.values then
walkSchema(fn, schemaNode.values, utilsTable.concat(path, "values"))
end
if schemaNode.properties then
for i, v in ipairs(schemaNode.properties) do
local keyPath = utilsTable.concat(path, "properties", v.name)
walkSchema(fn, v, keyPath)
end
end
if schemaNode.oneOf then
for k, v in pairs(schemaNode.oneOf) do
walkSchema(fn, v, utilsTable.concat(path, "oneOf"))
end
end
if schemaNode.allOf then
for i, v in ipairs(schemaNode.allOf) do
walkSchema(fn, v, utilsTable.concat(path, "allOf"))
end
end
if schemaNode.definitions then
for k, v in pairs(schemaNode.definitions) do
walkSchema(fn, v, utilsTable.concat(path, "definitions", k))
end
end
end
local function collectIdReferences(schema)
walkSchema(function(schemaNode)
if schemaNode._id then
references[schemaNode._id] = schemaNode
end
end, schema)
end
local function collectReferences(schema)
references = {} -- use of global variable here not ideal
references["#"] = schema
for k, v in pairs(schema.definitions or {}) do
references["#/definitions/" .. k] = v
collectIdReferences(v)
end
collectIdReferences(schema)
end
function resolveReference(schemaNode)
if schemaNode._ref then
local referenceNode = references[schemaNode._ref]
if not referenceNode then
mw.addWarning(string.format("<code>%s</code> not found", mw.text.nowiki(schemaNode._ref)))
else
local resolvedSchema = utilsTable.merge({}, references[schemaNode._ref], schemaNode)
schemaNode = utilsTable.merge({}, schemaNode, resolvedSchema)
schemaNode._ref = nil
end
end
return schemaNode
end
local function luaType(schemaType)
if schemaType == TYPES.array
or schemaType == TYPES.record
or schemaType == TYPES.map
then
return "table"
end
return schemaType
end
local function errorCollector(errorTbl, value, dataName, dataPath, quiet, isKey)
return function(validator)
local errorMessages = validator(value, dataName, dataPath, isKey, {
quiet = quiet,
stackTrace = false,
})
if type(errorMessages) ~= "table" then --errMsg can either be a single message, or an array of messages (as is the case with enum)
errorMessages = {errorMessages}
end
for _, errMsg in ipairs(errorMessages) do
table.insert(errorTbl, {
path = dataName .. utilsTable.printPath(dataPath),
msg = errMsg,
})
end
end
end
local function validatePrimitive(schemaNode, value, dataName, dataPath, quiet, isKey)
local errors = {}
local addIfError = errorCollector(errors, value, dataName, dataPath, quiet, isKey)
local validatorOptions = {
quiet = quiet,
stackTrace = false,
}
if schemaNode.type and schemaNode.type ~= "any" then
local expectedType = luaType(schemaNode.type)
addIfError(utilsValidate.type(expectedType))
end
if schemaNode.required then
addIfError(utilsValidate.required)
end
if schemaNode.deprecated then
addIfError(utilsValidate.deprecated)
end
if schemaNode.enum then
addIfError(utilsValidate.enum(schemaNode.enum))
end
return errors
end
local function logSubschemaErrors(subschemaErrors, path)
for schemaKey, schemaErrors in pairs(subschemaErrors) do
local subpath = utilsTable.concat(path or {}, '["' .. schemaKey .. '"]')
local indent = string.rep(":", #subpath)
for _, err in pairs(schemaErrors) do
local msg = string.format("<code>%s</code>: %s", utilsTable.printPath(subpath), err.msg)
utilsError.warn(indent .. msg, { traceBack = false })
if err.errors then
logSubschemaErrors(err.errors, subpath)
end
end
end
end
local function validateSubschemas(subschemas, data, dataName, dataPath, schemaNode, parentSchema)
local errors = {}
local valids = {}
for k, subschema in pairs(subschemas) do
local key = subschema._ref and string.gsub(subschema._ref, "#/definitions/", "") or k
if parentSchema and parentSchema.allOf then
local commonProps = {}
for _, v in ipairs(parentSchema.allOf) do
commonProps = utilsTable.concat(commonProps, v.properties)
end
schemaNode.allOfProps = commonProps
end
local err = doValidate(subschema, data, dataName, dataPath, schemaNode, true)
if #err > 0 then
errors[key] = err
else
table.insert(valids, key)
end
end
return errors, valids
end
function doValidate(schemaNode, data, dataName, dataPath, parentSchema, quiet)
dataPath = dataPath or {}
local value = extract(data, dataPath)
local errPath = dataName .. utilsTable.printPath(dataPath)
schemaNode = resolveReference(schemaNode)
local errors = validatePrimitive(schemaNode, value, dataName, dataPath, quiet)
if #errors > 0 then
return errors
end
if schemaNode.allOf and value then
local subschemaErrors = validateSubschemas(schemaNode.allOf, data, dataName, dataPath, schemaNode)
local invalidSubschemas = utilsTable.keys(subschemaErrors)
if #invalidSubschemas > 0 then
invalidSubschemas = utilsTable.map(invalidSubschemas, util.markup.code)
invalidSubschemas = mw.text.listToText(invalidSubschemas)
local msg = string.format("<code>%s</code> does not match <code>allOf</code> sub-schemas %s", errPath, invalidSubschemas)
if not quiet then
utilsError.warn(msg, { traceBack = false })
logSubschemaErrors(subschemaErrors)
end
table.insert(errors, {
path = errPath,
msg = msg,
errors = subschemaErrors
})
end
end
if schemaNode.oneOf and value then
local subschemaErrors, validSubschemas = validateSubschemas(schemaNode.oneOf, data, dataName, dataPath, schemaNode, parentSchema)
local invalidSubschemas = utilsTable.keys(subschemaErrors)
if #validSubschemas == 0 then
local msg = string.format("<code>%s</code> does not match any <code>oneOf</code> sub-schemas.", errPath)
if not quiet then
utilsError.warn(msg, { traceBack = false })
logSubschemaErrors(subschemaErrors)
end
table.insert(errors, {
path = errPath,
msg = msg,
errors = subschemaErrors
})
end
if #validSubschemas > 1 then
validSubschemas = utilsTable.map(validSubschemas, util.markup.code)
validSubschemas = mw.text.listToText(validSubschemas)
local msg = string.format("<code>%s</code> matches <code>oneOf</code> sub-schemas %s, but must match only one.", errPath, validSubschemas)
if not quiet then
utilsError.warn(msg, { traceBack = false })
end
table.insert(errors, {
path = errPath,
msg = msg,
})
end
end
if schemaNode.properties and value then
for _, propSchema in pairs(schemaNode.properties) do
local keyPath = utilsTable.concat(dataPath, propSchema.name)
errors = utilsTable.concat(errors, doValidate(propSchema, data, dataName, keyPath, schemaNode, quiet))
end
if not schemaNode.additionalProperties and not (parentSchema and parentSchema.allOf) then
local schemaProps = schemaNode.properties
if parentSchema and parentSchema.allOfProps then
schemaProps = utilsTable.concat(schemaProps, parentSchema.allOfProps)
end
local schemaProps = utilsTable.map(schemaProps, "name")
local dataProps = utilsTable.keys(value)
local undefinedProps = utilsTable.difference(dataProps, schemaProps)
if #undefinedProps > 0 then
undefinedProps = mw.text.listToText(utilsTable.map(undefinedProps, util.markup.code))
local msg = string.format("Record <code>%s</code> has undefined properties: %s", errPath, undefinedProps)
if not quiet then
utilsError.warn(msg, { traceBack = false })
end
table.insert(errors, {
path = errPath,
msg = msg,
})
end
end
end
if schemaNode.items and value then
local props = utilsTable.stringKeys(value)
if #props > 0 and not (parentSchema and parentSchema.allOf) then
local msg = string.format("<code>%s</code> is supposed to be an array only, but it has string keys: %s", errPath, utilsTable.print(props))
if not quiet then
utilsError.warn(msg, { traceBack = false })
end
table.insert(errors, {
path = errPath,
msg = msg,
})
end
for i, item in ipairs(value) do
local itemPath = utilsTable.concat(dataPath, i)
errors = utilsTable.concat(errors, doValidate(schemaNode.items, data, dataName, itemPath, schemaNode, quiet))
end
end
if schemaNode.keys and schemaNode.values and value then
for k, v in pairs(value) do
local keyPath = utilsTable.concat(dataPath, string.format('["%s"]', k))
errors = utilsTable.concat(errors, validatePrimitive(schemaNode.keys, k, dataName, dataPath, quiet, true))
errors = utilsTable.concat(errors, doValidate(schemaNode.values, data, dataName, keyPath, schemaNode, quiet))
end
end
return errors
end
local function validate(schema, schemaName, data, dataName)
-- first validate the schema itself
collectReferences(metaSchema)
local schemaErrors = doValidate(metaSchema.values, schema, schemaName)
if #schemaErrors > 0 then
utilsError.warn(string.format("Schema <code>%s</code> is invalid.", schemaName))
end
-- then validate the data
collectReferences(schema)
local errors = doValidate(schema, data, dataName)
if #errors > 0 then
utilsError.warn(string.format("<code>%s</code> is invalid according to schema <code>%s</code>", dataName, schemaName))
return errors
end
end
return validate