Module:Util/schema/validate: Difference between revisions

From Zelda Wiki, the Zelda encyclopedia
Jump to navigation Jump to search
No edit summary
No edit summary
 
(3 intermediate revisions by the same user not shown)
Line 1: Line 1:
local h = {}
-- I apologize for how big a mess this is.


local utilsError = require("Module:UtilsError")
local utilsError = require("Module:UtilsError")
Line 13: Line 13:
}
}


local metaSchema = require("Module:Util/schemas/validate/Documentation/Spec").Schemas()
local metaSchema = require("Module:Util/schema/validate/Documentation/Spec").Schemas()


local SYMBOLS = {
local SYMBOLS = {
Line 42: Line 42:
end
end


local function validate(schema, schemaName, data, dataName)
local function walkSchema(fn, schemaNode, path)
-- first validate the schema itself
h.collectReferences(metaSchema)
local schemaErrors = h.validate(metaSchema.values, schema, schemaName)
if #schemaErrors > 0 then
utilsError.warn(string.format("Schema <code>%s</code> is invalid.", schemaName))
end
-- then validate the data
h.collectReferences(schema)
local errors = h.validate(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
 
function h.collectReferences(schema)
h.references = {}
h.references["#"] = schema
for k, v in pairs(schema.definitions or {}) do
h.references["#/definitions/" .. k] = v
h.collectIdReferences(v)
end
h.collectIdReferences(schema)
end
function h.collectIdReferences(schema)
h.walkSchema(function(schemaNode)
if schemaNode._id then
h.references[schemaNode._id] = schemaNode
end
end, schema)
end
 
function h.resolveReference(schemaNode)
if schemaNode._ref then
local referenceNode = h.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({}, h.references[schemaNode._ref], schemaNode)
schemaNode = utilsTable.merge({}, schemaNode, resolvedSchema)
schemaNode._ref = nil
end
end
return schemaNode
end
 
function h.walkSchema(fn, schemaNode, path)
path = path or {}
path = path or {}
local continue = fn(schemaNode, path)
local continue = fn(schemaNode, path)
Line 96: Line 49:
end
end
if schemaNode.items then
if schemaNode.items then
h.walkSchema(fn, schemaNode.items, utilsTable.concat(path, "items"))
walkSchema(fn, schemaNode.items, utilsTable.concat(path, "items"))
end
end
if schemaNode.keys then
if schemaNode.keys then
h.walkSchema(fn, schemaNode.keys, utilsTable.concat(path, "keys"))
walkSchema(fn, schemaNode.keys, utilsTable.concat(path, "keys"))
end
end
if schemaNode.values then
if schemaNode.values then
h.walkSchema(fn, schemaNode.values, utilsTable.concat(path, "values"))
walkSchema(fn, schemaNode.values, utilsTable.concat(path, "values"))
end
end
if schemaNode.properties then
if schemaNode.properties then
for i, v in ipairs(schemaNode.properties) do
for i, v in ipairs(schemaNode.properties) do
local keyPath = utilsTable.concat(path, "properties", v.name)
local keyPath = utilsTable.concat(path, "properties", v.name)
h.walkSchema(fn, v, keyPath)
walkSchema(fn, v, keyPath)
end
end
end
end
if schemaNode.oneOf then
if schemaNode.oneOf then
for k, v in pairs(schemaNode.oneOf) do
for k, v in pairs(schemaNode.oneOf) do
h.walkSchema(fn, v, utilsTable.concat(path, "oneOf"))
walkSchema(fn, v, utilsTable.concat(path, "oneOf"))
end
end
end
end
if schemaNode.allOf then
if schemaNode.allOf then
for i, v in ipairs(schemaNode.allOf) do
for i, v in ipairs(schemaNode.allOf) do
h.walkSchema(fn, v, utilsTable.concat(path, "allOf"))
walkSchema(fn, v, utilsTable.concat(path, "allOf"))
end
end
end
end
if schemaNode.definitions then
if schemaNode.definitions then
for k, v in pairs(schemaNode.definitions) do
for k, v in pairs(schemaNode.definitions) do
h.walkSchema(fn, v, utilsTable.concat(path, "definitions", k))
walkSchema(fn, v, utilsTable.concat(path, "definitions", k))
end
end
end
end
end
end


-- This is to ensure that the documentation for recursive refs is shown only once.
local function collectIdReferences(schema)
function h.minRefDepths(schema)
walkSchema(function(schemaNode)
h.minDepthNode = {}
if schemaNode._id then
local minDepths = {}
references[schemaNode._id] = schemaNode
h.walkSchema(function(schemaNode, path)
if schemaNode._ref then
minDepths[schemaNode._ref] = math.min(minDepths[schemaNode._ref] or 9000, #path)
if #path == minDepths[schemaNode._ref] then
h.minDepthNode[schemaNode._ref] = schemaNode
end
end
end
end, schema)
end, schema)
end
end
function h.hasRefs(schema)
local function collectReferences(schema)
local hasRefs = false
references = {} -- use of global variable here not ideal
h.walkSchema(function(schemaNode)
references["#"] = schema
if schemaNode._ref then
for k, v in pairs(schema.definitions or {}) do
hasRefs = true
references["#/definitions/" .. k] = v
return false
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
end, schema)
return hasRefs
end
function h.showSubkeys(schemaNode)
local ref = schemaNode._ref
if schemaNode._hideSubkeys then
return false
elseif not ref then
return true
elseif not string.find(ref, "^#/definitions") then
return false
else
return h.minDepthNode[ref] == schemaNode or not h.hasRefs(h.references[ref])
end
end
return schemaNode
end
end


function h.getTypeDefs(schemaName, schema, formattingFn, parentSchema, isSubschema)
local function luaType(schemaType)
local typeLabel = schema.typeLabel
if schemaType == TYPES.array
local showSubkeys = true
or schemaType == TYPES.record
if schema._ref then
or schemaType == TYPES.map
typeLabel = typeLabel or string.gsub(schema._ref, "#/definitions/", "")
then
typeLabel = typeLabel or string.gsub(typeLabel, "#", "")
return "table"
showSubkeys = h.showSubkeys(schema)
end
end
schema = h.resolveReference(schema)
return schemaType
end
local rawType = schema.type
local function errorCollector(errorTbl, value, dataName, dataPath, quiet, isKey)  
local symbolicType
return function(validator)
local subkeys
local errorMessages = validator(value, dataName, dataPath, isKey, {
if showSubkeys then
quiet = quiet,
if schema.type == TYPES.record then
stackTrace = false,
for _, prop in ipairs(schema.properties) do
})
subkeys = subkeys or {}
if type(errorMessages) ~= "table" then --errMsg can either be a single message, or an array of messages (as is the case with enum)
local propDef = h.getTypeDefs(prop.name, prop, formattingFn, schema)
errorMessages = {errorMessages}
table.insert(subkeys, propDef)
end
end
end
if schema.type == TYPES.array then
for _, errMsg in ipairs(errorMessages) do
local subtypeKeys, subtype = h.getTypeDefs(nil, schema.items, formattingFn, schema)
table.insert(errorTbl, {
if #subtypeKeys > 0 then
path = dataName .. utilsTable.printPath(dataPath),
subkeys = subtypeKeys
msg = errMsg,
end
})
symbolicType = string.format(SYMBOLS.array, subtype)
end
end
if schema.type == TYPES.map then
end
local _, keyType = h.getTypeDefs(nil, schema.keys, formattingFn, schema)
end
local valueDef, valueType = h.getTypeDefs(nil, schema.values, formattingFn, schema)
local function validatePrimitive(schemaNode, value, dataName, dataPath, quiet, isKey)
symbolicType = string.format(SYMBOLS.map, keyType, valueType)
local errors = {}
subkeys = valueDef
local addIfError = errorCollector(errors, value, dataName, dataPath, quiet, isKey)
end
local validatorOptions = {
if schema.oneOf then
quiet = quiet,
subkeys = subkeys or {}
stackTrace = false,
subkeys.oneOf = subkeys.oneOf or {}
}
local subtypes = {}
if schemaNode.type and schemaNode.type ~= "any" then
local i = 1
local expectedType = luaType(schemaNode.type)
for k, subschema in pairs(schema.oneOf) do
addIfError(utilsValidate.type(expectedType))
if type(k) == "string" then
end
subschema.typeLabel = k
if schemaNode.required then
end
addIfError(utilsValidate.required)
local keys, subtype = h.getTypeDefs(nil, subschema, formattingFn, schema, true)
end
if string.find(subtype, "|") or string.find(subtype, "&") then
if schemaNode.deprecated then
subtype = string.format(SYMBOLS.combinationGroup, subtype)
addIfError(utilsValidate.deprecated)
end
end
table.insert(subtypes, subtype)
if schemaNode.enum then
subkeys.oneOf[i] = keys
addIfError(utilsValidate.enum(schemaNode.enum))
i = i + 1
end
end
return errors
symbolicType = subtypes[1]
end
for i, subtype in ipairs(utilsTable.tail(subtypes)) do
 
symbolicType = string.format(SYMBOLS.oneOf, symbolicType, subtype)
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
if schema.allOf then
end
subkeys = subkeys or {}
end
subkeys.allOf = {}
 
local subtypes = {}
local function validateSubschemas(subschemas, data, dataName, dataPath, schemaNode, parentSchema)
for i, subschema in ipairs(schema.allOf) do
local errors = {}
local keys, subtype = h.getTypeDefs(nil, subschema, formattingFn, schema)
local valids = {}
if string.find(subtype, "|") or string.find(subtype, "&") then
for k, subschema in pairs(subschemas) do
subtype = string.format(SYMBOLS.combinationGroup, subtype)
local key = subschema._ref and string.gsub(subschema._ref, "#/definitions/", "") or k
end
if parentSchema and parentSchema.allOf then
table.insert(subtypes, subtype)
local commonProps = {}
subkeys.allOf[i] = keys
for _, v in ipairs(parentSchema.allOf) do
end
commonProps = utilsTable.concat(commonProps, v.properties)
subtypes = utilsTable.unique(subtypes)
symbolicType = subtypes[1]
for i, subtype in ipairs(utilsTable.tail(subtypes)) do
symbolicType = string.format(SYMBOLS.allOf, symbolicType, subtype)
end
end
schemaNode.allOfProps = commonProps
end
end
end
local err = doValidate(subschema, data, dataName, dataPath, schemaNode, true)
symbolicType = symbolicType or rawType or typeLabel
if #err > 0 then
errors[key] = err
local parentType = parentSchema and parentSchema.type
if parentType == "array" and typeLabel then
symbolicType = typeLabel
end
local key = schemaName
if schema.default then
key = key and string.format(SYMBOLS.default, schemaName, tostring(schema.default))
end
if parentSchema == nil or not (parentSchema.allOf or parentSchema.oneOf or parentType == TYPES.array or parentType == TYPES.map) then  -- otherwise leads to nonsense like [[{[string]}]|[[string]]], instead of [{string}|string]
if schema.required then
symbolicType = string.format(SYMBOLS.required, symbolicType)
else
else
symbolicType = string.format(SYMBOLS.optional, symbolicType)
table.insert(valids, key)
key = key and string.format(SYMBOLS.optional, key)
end
end
end
end
return errors, valids
local formattedDef = formattingFn({
key = key,
subkeys = subkeys,
rawType = rawType,
typeLabel = typeLabel,
symbolicType = symbolicType,
desc = schema.desc,
parentType = parentType,
isSubschema = isSubschema,
})
return formattedDef, symbolicType
end
end


function h.validate(schemaNode, data, dataName, dataPath, parentSchema, quiet)
function doValidate(schemaNode, data, dataName, dataPath, parentSchema, quiet)
dataPath = dataPath or {}
dataPath = dataPath or {}
local value = extract(data, dataPath)
local value = extract(data, dataPath)
local errPath = dataName .. utilsTable.printPath(dataPath)
local errPath = dataName .. utilsTable.printPath(dataPath)
schemaNode = h.resolveReference(schemaNode)
schemaNode = resolveReference(schemaNode)
local errors = h.validatePrimitive(schemaNode, value, dataName, dataPath, quiet)
local errors = validatePrimitive(schemaNode, value, dataName, dataPath, quiet)
if #errors > 0 then
if #errors > 0 then
return errors
return errors
Line 283: Line 208:
if schemaNode.allOf and value then
if schemaNode.allOf and value then
local subschemaErrors = h.validateSubschemas(schemaNode.allOf, data, dataName, dataPath, schemaNode)
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 291: Line 216:
if not quiet then
if not quiet then
utilsError.warn(msg, { traceBack = false })
utilsError.warn(msg, { traceBack = false })
h.logSubschemaErrors(subschemaErrors)
logSubschemaErrors(subschemaErrors)
end
end
table.insert(errors, {
table.insert(errors, {
Line 301: Line 226:
end
end
if schemaNode.oneOf and value then
if schemaNode.oneOf and value then
local subschemaErrors, validSubschemas = h.validateSubschemas(schemaNode.oneOf, data, dataName, dataPath, schemaNode, parentSchema)
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 307: Line 232:
if not quiet then
if not quiet then
utilsError.warn(msg, { traceBack = false })
utilsError.warn(msg, { traceBack = false })
h.logSubschemaErrors(subschemaErrors)
logSubschemaErrors(subschemaErrors)
end
end
table.insert(errors, {
table.insert(errors, {
Line 332: 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, h.validate(propSchema, data, dataName, keyPath, schemaNode, quiet))
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 369: 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, h.validate(schemaNode.items, data, dataName, itemPath, schemaNode, quiet))
errors = utilsTable.concat(errors, doValidate(schemaNode.items, data, dataName, itemPath, schemaNode, quiet))
end
end
end
end
Line 375: 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, h.validatePrimitive(schemaNode.keys, k, dataName, dataPath, quiet, true))
errors = utilsTable.concat(errors, validatePrimitive(schemaNode.keys, k, dataName, dataPath, quiet, true))
errors = utilsTable.concat(errors, h.validate(schemaNode.values, data, dataName, keyPath, schemaNode, quiet))
errors = utilsTable.concat(errors, doValidate(schemaNode.values, data, dataName, keyPath, schemaNode, quiet))
end
end
end
end
Line 383: Line 308:
end
end


function h.validateSubschemas(subschemas, data, dataName, dataPath, schemaNode, parentSchema)
local function validate(schema, schemaName, data, dataName)
local errors = {}
-- first validate the schema itself
local valids = {}
collectReferences(metaSchema)
for k, subschema in pairs(subschemas) do
local schemaErrors = doValidate(metaSchema.values, schema, schemaName)
local key = subschema._ref and string.gsub(subschema._ref, "#/definitions/", "") or k
if #schemaErrors > 0 then
if parentSchema and parentSchema.allOf then
utilsError.warn(string.format("Schema <code>%s</code> is invalid.", schemaName))
local commonProps = {}
for _, v in ipairs(parentSchema.allOf) do
commonProps = utilsTable.concat(commonProps, v.properties)
end
schemaNode.allOfProps = commonProps
end
local err = h.validate(subschema, data, dataName, dataPath, schemaNode, true)
if #err > 0 then
errors[key] = err
else
table.insert(valids, key)
end
end
end
return errors, valids
-- then validate the data
end
collectReferences(schema)
function h.logSubschemaErrors(subschemaErrors, path)
local errors = doValidate(schema, data, dataName)
for schemaKey, schemaErrors in pairs(subschemaErrors) do
if #errors > 0 then
local subpath = utilsTable.concat(path or {}, '["' .. schemaKey .. '"]')
utilsError.warn(string.format("<code>%s</code> is invalid according to schema <code>%s</code>", dataName, schemaName))
local indent = string.rep(":", #subpath)
return errors
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
h.logSubschemaErrors(err.errors, subpath)
end
end
end
end
 
function h.validatePrimitive(schemaNode, value, dataName, dataPath, quiet, isKey)
local errors = {}
local addIfError = h.errorCollector(errors, value, dataName, dataPath, quiet, isKey)
local validatorOptions = {
quiet = quiet,
stackTrace = false,
}
if schemaNode.type and schemaNode.type ~= "any" then
local expectedType = h.getLuaType(schemaNode.type)
addIfError(utilsValidate.type(expectedType))
end
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
function h.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
function h.getLuaType(schemaType)
if schemaType == TYPES.array
or schemaType == TYPES.record
or schemaType == TYPES.map
then
return "table"
end
return schemaType
end
end


return validate
return validate

Latest revision as of 22:04, 13 May 2024

validate(schema, schemaName, data, dataName)

Returns

  • An array of error paths and messages, or nil if there are none.

Examples

#InputOutputStatus
Basic record with four fields.
1
validate(
  {
    type = "record",
    properties = {
      {
        type = "string",
        name = "name",
        required = true,
      },
      {
        name = "games",
        type = "array",
        items = {
          type = "string",
          enum = {"TLoZ", "TAoL", "ALttP"},
        },
      },
      {
        name = "cost",
        type = "number",
      },
      {
        deprecated = true,
        type = "string",
        name = "deprecatedField",
      },
    },
  },
  "gameItem",
  {
    cost = "50 Rupees",
    nonsenseField = "foo",
    deprecatedField = "bar",
    games = {"YY", "ZZ"},
  },
  "itemVariable"
)
{
  {
    msg = "<code>itemVariable.name</code> is required but is <code>nil</code>.",
    path = "itemVariable.name",
  },
  {
    msg = '<code>itemVariable.games[1]</code> has unexpected value <code>YY</code>. The accepted values are: <code>{"TLoZ", "TAoL", "ALttP"}</code>',
    path = "itemVariable.games[1]",
  },
  {
    msg = '<code>itemVariable.games[2]</code> has unexpected value <code>ZZ</code>. The accepted values are: <code>{"TLoZ", "TAoL", "ALttP"}</code>',
    path = "itemVariable.games[2]",
  },
  {
    msg = "<code>itemVariable.cost</code> is type <code>string</code> but type <code>number</code> was expected.",
    path = "itemVariable.cost",
  },
  {
    msg = "<code>itemVariable.deprecatedField</code> is deprecated but has value <code>bar</code>.",
    path = "itemVariable.deprecatedField",
  },
  {
    msg = "Record <code>itemVariable</code> has undefined properties: <code>nonsenseField</code>",
    path = "itemVariable",
  },
}
Map validation.
2
validate(
  {
    type = "map",
    keys = { type = "string" },
    values = { type = "string" },
  },
  "Games",
  {
    [3] = "A Link to the Past",
    OoA = "Oracle of Ages",
    OoT = 5,
  },
  "games"
)
{
  {
    msg = "<code>games</code> key <code>3</code> is type <code>number</code> but type <code>string</code> was expected.",
    path = "games",
  },
  {
    msg = '<code>games["OoT"]</code> is type <code>number</code> but type <code>string</code> was expected.',
    path = 'games["OoT"]',
  },
}
A schema using oneOf and an ID-based reference.
3
validate(
  {
    oneOf = {
      {
        type = "number",
        _id = "#num",
      },
      {
        type = "array",
        items = { _ref = "#num" },
      },
    },
  },
  "numberOrArray",
  {"foo"},
  "arg"
)
{
  {
    path = "arg",
    msg = "<code>arg</code> does not match any <code>oneOf</code> sub-schemas.",
    errors = {
      {
        {
          msg = "<code>arg</code> is type <code>table</code> but type <code>number</code> was expected.",
          path = "arg",
        },
      },
      {
        {
          msg = "<code>arg[1]</code> is type <code>string</code> but type <code>number</code> was expected.",
          path = "arg[1]",
        },
      },
    },
  },
}
Schema using oneOf and definitions references. A simplification of Module:Documentation's schema.
4
validate(
  {
    oneOf = {
      { _ref = "#/definitions/functions" },
      { _ref = "#/definitions/sections" },
    },
    definitions = {
      sections = {
        type = "record",
        properties = {
          {
            name = "sections",
            type = "array",
            items = { _ref = "#/definitions/functions" },
          },
        },
      },
      functions = {
        type = "array",
        items = { type = "string" },
      },
    },
  },
  "Documentation",
  {
    "foo",
    sections = {
      {"bar", "baz"},
      {"quux"},
    },
  },
  "doc"
)
{
  {
    path = "doc",
    msg = "<code>doc</code> does not match any <code>oneOf</code> sub-schemas.",
    errors = {
      sections = {
        {
          msg = "Record <code>doc</code> has undefined properties: <code>1</code>",
          path = "doc",
        },
      },
      functions = {
        {
          msg = '<code>doc</code> is supposed to be an array only, but it has string keys: {"sections"}',
          path = "doc",
        },
      },
    },
  },
}
Data is invalid if it matches more than one oneOf.
5
validate(
  {
    oneOf = {
      { type = "string" },
      { type = "string" },
    },
  },
  "Schema",
  "Fooloo Limpah",
  "magicWords"
)
{
  {
    msg = "<code>magicWords</code> matches <code>oneOf</code> sub-schemas <code>1</code> and <code>2</code>, but must match only one.",
    path = "magicWords",
  },
}
A schema using allOf.
6
validate(
  {
    allOf = {
      {
        type = "record",
        properties = {
          {
            name = "foo",
            type = "string",
          },
        },
      },
      {
        type = "array",
        items = { type = "number" },
      },
    },
  },
  "Schema",
  {1, 2, 3, foo = 4},
  "arg"
)
{
  {
    path = "arg",
    msg = "<code>arg</code> does not match <code>allOf</code> sub-schemas <code>1</code>",
    errors = {
      {
        {
          msg = "<code>arg.foo</code> is type <code>number</code> but type <code>string</code> was expected.",
          path = "arg.foo",
        },
      },
    },
  },
}

-- 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/schema/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