Module:UtilsSchema

local p = {} local h = {}

local utilsArg = require("Module:UtilsArg") local utilsError = require("Module:UtilsError") local utilsTable = require("Module:UtilsTable")

local SYMBOLS = { optional = "[%s]", required = "%s!", default = "%s=%s", array = "{%s}", oneOf = "%s|%s", map = "<%s,%s>" }

p.TYPES = { oneOf = "oneOf", array = "array", record = "record", map = "map", }

function p.Validate(frame) local docPage = mw.title.new(frame:getParent:getTitle) local dataPage = docPage.basePageTitle if mw.title.getCurrentTitle.text ~= dataPage.text then return "" end local modulePage = dataPage.basePageTitle local module = require(modulePage.fullText) if not module.Schemas then error(string.format("Module '%s' does not export any schemas.", modulePage.text)) end local schemaName = frame.args[1] or dataPage.subpageText if not module.Schemas[schemaName] then error(string.format("Module '%s' has no such schema '%s'.", modulePage.text, schemaName)) end local data = mw.loadData(dataPage.fullText) local err = h.validate(module.Schemas, schemaName, data, "data") return err end

function p.validate(schemas, schemaName, data, dataName) local schema = h.resolveReferences(schemas[schemaName], h.collectReferences(schemas)) return h.validate(schema, data, dataName) end

function p.getTypeDefinitions(schemas, schemaName, formattingFn) local schema = h.resolveReferences(schemas[schemaName], h.collectReferences(schemas)) return h.getTypeDefs(schemaName, schema, formattingFn) end

function h.collectReferences(schemas, references) references = references or {} for k, schema in pairs(schemas) do references["#/" .. k] = schema h.walkSchema(function(schemaNode)			if schemaNode._id then				references[schemaNode._id] = schemaNode			end		end, schema) end return references end

function h.resolveReferences(schema, references) h.walkSchema(function(schemaNode)		if schemaNode._ref then			local resolvedSchema = utilsTable.merge({}, references[schemaNode._ref], schemaNode)			utilsTable.merge(schemaNode, resolvedSchema)			schemaNode._ref = nil		end	end, schema) return schema end

function h.walkSchema(fn, schemaNode, path) path = path or {} local continue = fn(schemaNode, path) if continue == false then return end if schemaNode.items then h.walkSchema(fn, schemaNode.items, path) end if schemaNode.keys then h.walkSchema(fn, schemaNode.keys, path) end if schemaNode.values then h.walkSchema(fn, schemaNode.values, path) end if schemaNode.properties then for i, v in ipairs(schemaNode.properties) do			local keyPath = utilsTable.concat(path, v.name) h.walkSchema(fn, v, keyPath) end end if schemaNode.oneOf then for i, v in ipairs(schemaNode.oneOf) do			h.walkSchema(fn, v, path) end end end

function h.getTypeDefs(schemaName, schema, formattingFn, parentSchema) local rawType = schema.type local symbolicType local subkeys if rawType == p.TYPES.record then subkeys = {} for _, prop in ipairs(schema.properties) do			local propDef = h.getTypeDefs(prop.name, prop, formattingFn, schema) table.insert(subkeys, propDef) end elseif rawType == p.TYPES.array then local subtypeKeys, subtype = h.getTypeDefs(nil, schema.items, formattingFn, schema) subkeys = subtypeKeys symbolicType = string.format(SYMBOLS.array, subtype) elseif rawType == p.TYPES.map then local _, keyType = h.getTypeDefs(nil, schema.keys, formattingFn, schema) local valueDef, valueType = h.getTypeDefs(nil, schema.values, formattingFn, schema) symbolicType = string.format(SYMBOLS.map, keyType, valueType) subkeys = valueDef elseif schema.oneOf then rawType = p.TYPES.oneOf subkeys = {} local subtypes = {} for i, subschema in ipairs(schema.oneOf) do			local keys, subtype = h.getTypeDefs(i == 1 and "" or "OR", subschema, formattingFn, schema) table.insert(subkeys, keys) if i == 1 then symbolicType = subtype else symbolicType = string.format(SYMBOLS.oneOf, symbolicType, subtype) end end end symbolicType = symbolicType or rawType local parentType if parentSchema then parentType = parentSchema.type or (parentSchema.oneOf and p.TYPES.oneOf) end local key = schemaName if schema.default then key = key and string.format(SYMBOLS.default, schemaName, schema.default) end if schema.required then symbolicType = string.format(SYMBOLS.required, symbolicType) elseif parentType ~= p.TYPES.oneOf and parentType ~= p.TYPES.array and parentType ~= p.TYPES.map then -- otherwise leads to nonsense like [[string], instead of [{string}|string]		symbolicType = string.format(SYMBOLS.optional, symbolicType)		key = key and string.format(SYMBOLS.optional, key)	end	if schema._showDoc == false then		subkeys = nil	end	local formattedDef = formattingFn({		key = key,		rawType = rawType,		symbolicType = symbolicType,		desc = schema.desc,		parentType = parentType,		subkeys = subkeys,	})	return formattedDef, symbolicType end

function h.validate(schema, data, dataName, path) path = path or {} local errors = {} h.walkSchema(function(schemaNode, nodePath)		local nodeErrors = {}		local value = utilsTable.property(nodePath)(data)		if schemaNode.oneOf then			local subschemaErrors = {}			local validSchemas = {}			for i, node in ipairs(schemaNode.oneOf) do				local err, msg = h.validate(node, data, dataName, nodePath)				if not err then					table.insert(validSchemas, i)				else					subschemaErrors[i] = msg				end			end			local path = dataName .. utilsTable.path(nodePath)			if #validSchemas == 0 then				local msg = string.format("%s does not match any of the schemas valid for the path.", code(path))				utilsError.warn(msg)				table.insert(errors, { path = path, msg = msg, errors = subschemaErrors })			elseif #validSchemas > 1 then				validSchemas = utilsTable.map(code)(validSchemas)				validSchemas = mw.text.listToText(validSchemas)				local msg = string.format("%s matches  schemas %s, but must match only one.", code(path), validSchemas)				utilsError.warn(msg)				table.insert(errors, { path = path, msg = msg, })			end			return false		end		if schemaNode.items then			for i, item in ipairs(value or {}) do				local itemPath = utilsTable.concat(nodePath, i)				local _, itemErrors = h.validate(schemaNode.items, data, dataName, itemPath)				errors = utilsTable.concat(errors, itemErrors)			end			return false		end		if schemaNode.type == p.TYPES.map then			for k, v in pairs(value or {}) do				local keyErrors = h.validateValue(k, schemaNode.keys, dataName, nodePath, true)				local valuePath = utilsTable.concat(nodePath, k)				local _, valueErrors = h.validate(schemaNode.values, data, dataName, valuePath)				errors = utilsTable.concat(errors, keyErrors, valueErrors)			end			return false		end		local valueErrors = h.validateValue(value, schemaNode, dataName, nodePath)		nodeErrors = utilsTable.concat(nodeErrors, valueErrors)		if #nodeErrors > 0 then			errors = utilsTable.concat(errors, nodeErrors)			return false end return true end, schema, path)	return (#errors > 0 and "Category:Pages with invalid data" or nil), errors end

function h.validateValue(value, schemaNode, dataName, path, isKey) local errors = {} local addIfError = h.addIfError(errors, dataName, path, value, isKey) local expectedType = h.getLuaType(schemaNode.type) addIfError(utilsArg.type(expectedType)) if schemaNode.required then addIfError(utilsArg.required) end if schemaNode.enum then addIfError(utilsArg.enum(schemaNode.enum)) end return errors end

function h.addIfError(errorTable, dataName, nodePath, value, isKey) return function(validator) local err, msg = validator(value, dataName, nodePath, isKey) if err then table.insert(errorTable, {				path = dataName .. utilsTable.path(nodePath),				msg = msg,			}) end end end

function h.getLuaType(schemaType) if schemaType == p.TYPES.array or schemaType == p.TYPES.record or schemaType == p.TYPES.map then return "table" end return schemaType end

p.Documentation = { {		outputOnly = true, name = "validate", params = { {			 	name = "schemas", description = "Collection of related schemas to validate against.", },			{				name = "schemaName", description = "A key in the above table—the particular schema to validate against, which may refernce the other schemas provided.", },			{				name = "data", description = "The data to validate against the given schema.", },			{				name = "dataName", description = "A string used in warning messages to represent the data as a variable." },		},		returns = { "The name of an error category if there are validation errors, nil otherwise", "An array of paths and messages.", },		cases = { {				description = "Basic record with three fields.", args = { {						gameItem = { type = "record", properties = { {									name = "name", type = "string", required = true, },								{									name = "games", type = "array", items = { type = "string", enum = { "TLoZ", "TAoL", "ALttP", "other" }, },								},								{									name = "cost", type = "number", }							}						},					},					"gameItem", {						cost = "50 Rupees", games = { "💩" }, },					"itemVariable" },				expected = { "Category:Pages with invalid data", {						{							path = 'itemVariable["name"]', msg = ' is required but is  .', },						{							path = 'itemVariable["games"][1]', msg = ' has unexpected value. The accepted values are: ', },						{							path = 'itemVariable["cost"]', msg = ' is type   but type   was expected.', },					},				},			},			{				description = "Map validation.", args = { {						Games = { type = "map", keys = { type = "string" }, values = { type = "string" }, },					},					"Games", {						OoA = "Oracle of Ages", OoT = 5, [3] = "A Link to the Past", },					"games" },				expected = { "Category:Pages with invalid data", {						{							path = "games", msg = " key   is type   but type   was expected.", },						{							path = 'games["OoT"]', msg = ' is type   but type   was expected.', },					}				}			},			{				description = "A schema using  and an ID-based reference.", args = { {						numberOrArray = { oneOf = { {									_id = "#num", type = "number", },								{									type = "array", items = { _ref = "#num"}, },							},						},					},					"numberOrArray", { "foo" }, "arg", },				expected = { "Category:Pages with invalid data", {						{						msg = " does not match any of the schemas valid for the path.", path = "arg", errors = { {									{										path = "arg", msg = " is type   but type   was expected.", },								},								{									{										path = "arg[1]", msg = " is type   but type   was expected.", },								},							},						},					},				},			},			{				description = "A schema using  and ID references. A simplification of Module:Documentation's schema.", args = { {						Documentation = { oneOf = { { _ref = "#/functions", }, { _ref = "#/sections", }, }						},						functions = { type = "array", items = { type = "string" }, },						sections = { type = "record", properties = { {									name = "sections", type = "array", items = { _ref = "#/functions" } },							},						},					},					"Documentation", {						"foo", sections = { {"bar", "baz"}, { "quux" }, },					},					"doc", },				expected = { "Category:Pages with invalid data", {						{							path = "doc", msg = " matches   schemas   and , but must match only one.", },					}				},			},		},	}, }

return p