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(schemas, schemaName, data, dataName) -- first validate the schema itself local schemaErrors = h.validate(h.init(p.Schemas, "Schema"), schemas[schemaName], schemaName) if #schemaErrors > 0 then utilsError.warn(string.format("Schema  is invalid.", schemaName)) end -- then validate the data local schema = h.init(schemas, schemaName) local errors = h.validate(schema, data, dataName) if #errors > 0 then utilsError.warn(string.format(" is invalid according to schema  ", dataName, schemaName)) return "Category:Modules with invalid data", errors end end

function p.getTypeDefinitions(schemas, schemaName, formattingFn) local schema = h.init(schemas, schemaName) return h.getTypeDefs(schemaName, schema, formattingFn) end

function h.init(schemas, schemaName) schemas = mw.clone(schemas) h.collectReferences(schemas) for k, schema in pairs(schemas) do		h.mergeAllOfs(schema) end local schema = schemas[schemaName] return schema end

h.references = {}

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

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

function h.mergeAllOfs(schema) h.walkSchema(function(schemaNode)		if schemaNode.allOf then			h.mergeSubschemas(schemaNode, unpack(schemaNode.allOf))			schemaNode.allOf = nil		end	end, schema) end h.mergeSubschemas = utilsTable.mergeWith(function(a, b)	if a.type and b.type and a.type ~= b.type then		local msg = string.format("Use of  on subchemas of different types (  and  ) is not supported. Defaulting to type  ", a.type, b.type, a.type)		utilsError.warn(msg)		a.type = b.type		return a	end	if utilsTable.isArray(a) then		return utilsTable.concat(a, b)	end 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 if schemaNode.allOf then for i, v in ipairs(schemaNode.allOf) do			h.walkSchema(fn, v, path) end end end

function h.getTypeDefs(schemaName, schema, formattingFn, parentSchema, isSubschema) local rawType if schema._showDoc == false and schema._ref then rawType = string.gsub(schema._ref, "#/", "") else h.resolveReferences(schema) rawType = schema.type end

local symbolicType local subkeys if schema._showDoc ~= false then if schema.type == p.TYPES.record then for _, prop in ipairs(schema.properties) do				subkeys = subkeys or {} local propDef = h.getTypeDefs(prop.name, prop, formattingFn, schema) table.insert(subkeys, propDef) end end if schema.type == p.TYPES.array then local subtypeKeys, subtype = h.getTypeDefs(nil, schema.items, formattingFn, schema) if #subtypeKeys > 0 then subkeys = subtypeKeys end symbolicType = string.format(SYMBOLS.array, subtype) end if schema.type == 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 end if schema.oneOf then for i, subschema in ipairs(schema.oneOf) do				subkeys = subkeys or {} subkeys.oneOf = subkeys.oneOf or {} local keys, subtype = h.getTypeDefs(subschema.label or "", subschema, formattingFn, schema, true) subkeys.oneOf[i] = keys if i == 1 then symbolicType = subtype else symbolicType = string.format(SYMBOLS.oneOf, symbolicType, subtype) end end end end symbolicType = symbolicType or rawType local parentType = parentSchema and parentSchema.type 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 not isSubschema 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	local formattedDef = formattingFn({		key = key,		subkeys = subkeys,		rawType = rawType,		symbolicType = symbolicType,		desc = schema.desc,		parentType = parentType,		isSubschema = isSubschema,	})	return formattedDef, symbolicType end

function h.validate(schema, data, dataName, path, quiet) path = path or {} local errors = {} h.walkSchema(function(schemaNode, nodePath)		h.resolveReferences(schemaNode)		local nodeErrors = {}		local value = utilsTable.property(nodePath)(data)		if schemaNode.oneOf and value then			local subschemaErrors = {}			local validSchemas = {}			for i, node in ipairs(schemaNode.oneOf) do				local err = h.validate(node, data, dataName, nodePath, true)				if #err == 0 then					table.insert(validSchemas, i)				else					subschemaErrors[i] = err				end			end			local errPath = 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(errPath))				utilsError.warn(msg, false)				table.insert(errors, { path = errPath, msg = msg, errors = subschemaErrors })				local errMessages = utilsTable.map("msg")(utilsTable.flatten(subschemaErrors))				for _, errMessage in ipairs(errMessages) do					utilsError.warn(errMessage, false)				end			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(errPath), validSchemas)				utilsError.warn(msg, false)				table.insert(errors, { path = errPath, 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, quiet)				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, quiet)				local valuePath = utilsTable.concat(nodePath, k)				local valueErrors = h.validate(schemaNode.values, data, dataName, valuePath, quiet)				errors = utilsTable.concat(errors, keyErrors, valueErrors)			end			return false		end		if schemaNode.properties and type(value) == "table" then			local schemaProps = utilsTable.map("name")(schemaNode.properties)			local dataProps = utilsTable.keys(value)			dataProps = utilsTable.filter(function(key) return type(key) == "string" end)(dataProps) local undefinedProps = utilsTable.difference(dataProps)(schemaProps) if #undefinedProps > 0 then undefinedProps = mw.text.listToText(utilsTable.map(code)(undefinedProps)) local errPath = code(dataName .. utilsTable.path(nodePath)) local msg = string.format("Record %s has undefined properties: %s", errPath, undefinedProps) if not quiet then utilsError.warn(msg, false) end table.insert(errors, {					path = errPath,					msg = msg,				}) end end local valueErrors = h.validateValue(value, schemaNode, dataName, nodePath, false, quiet) nodeErrors = utilsTable.concat(nodeErrors, valueErrors) if #nodeErrors > 0 then errors = utilsTable.concat(errors, nodeErrors) return false end if not value then return false end return true end, schema, path)	return errors end

function h.validateValue(value, schemaNode, dataName, path, isKey, quiet) local errors = {} local addIfError = h.addIfError(errors, dataName, path, value, isKey, quiet) if schemaNode.type ~= "any" then local expectedType = h.getLuaType(schemaNode.type) addIfError(utilsArg.type(expectedType)) end 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, quiet) return function(validator) local err, msg = validator(value, dataName, nodePath, isKey, {			quiet = quiet,			stackTrace = false,		}) 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.Schemas = { Schema = { oneOf = { { _ref = "#/primitive", label = "Primitive Schema", }, { _ref = "#/array", label = "Array Schema", }, { _ref = "#/record", label = "Record Schema", }, { _ref = "#/map", label = "Map Schema" }, },	},	base = { type = "record", properties = { {				name = "_id", type = "string", desc = "An ID to use for referencing.", },			{				name = "_ref", type = "string", desc = "A reference to another part of the schema. Combines this part of the schema with the one referenced. See examples.", },			{				name = "required", type = "boolean", desc = "If, the value cannot be  ." },			{				name = "oneOf", type = "array", desc = "An array of subschemas. The data must be valid against exactly one of them.", items = { _ref = "#/Schema", _showDoc = false }, },			{				name = "allOf", type = "array", desc = "An array of subschemas. The data must be valid against all of them.", items = { _ref = "#/Schema", _showDoc = false }, },		},	},	primitive = { allOf = { { _ref = "#/base" }, {				type = "record", properties = { {						name = "type", type = "string", enum = { "string", "number", "boolean", "any" }, desc = ', ,  , or  ' },					{						name = "enum", type = "array", items = { type = "any" }, desc = "An array of values that are considered acceptable.", },				},			}		}	},	array = { allOf = { { _ref = "#/base" }, {				type = "record", properties = { {						name = "type", type = "string", enum = { "array" }, desc = 'The string .' },					{						name = "items", _ref = "#/Schema", _showDoc = false, desc = "A schema that all items in the array must adhere to.", },				},				}		}	},	record = { allOf = { { _ref = "#/base" }, {				type = "record", properties = { {						name = "type", type = "string", enum = { "record" }, desc = 'The string .' },					{						name = "properties", type = "array", desc = "An array of schemas for each record entry, plus an additional field  for the name of the record entry.", items = { allOf = { {									type = "record", properties = { {											name = "name", required = true, type = "string", desc = "The key for the record entry." },									},								},								{									type = "record", properties = { {											_ref = "#/Schema", _showDoc = false, }									}								},							},						},					},				}			},		},	},	map = { allOf = { { _ref = "#/base" }, {				type = "record", properties = { {						name = "type", type = "string", required = true, enum = { "map" }, desc = 'The string .' },					{						name = "keys", type = "record", required = true, _ref = "#/Schema", _showDoc = false, desc = "The schema for the keys of the map." },					{						name = "values", type = "record", required = true, _ref = "#/Schema", _showDoc = false, desc = "The schema for the values of the map.", },				}			}		}	} }

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 = { "💩" }, nonsenseField = "foo", },					"itemVariable" },				expected = { "Category:Modules with invalid data", {						{							path = " ", msg = "Record  has undefined properties:  ", },						{							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:Modules 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:Modules 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:Modules with invalid data", {						{							path = "doc", msg = " matches   schemas   and , but must match only one.", },					}				},			},			{				description = "A schema using ", args = { {						Schema = { allOf = { { type = "number" }, { required = true }, { enum = { 0 } }, },						},					},					"Schema", "foo", "arg", },				expected = { "Category:Modules with invalid data", {						{							path = "arg", msg = " is type   but type   was expected.", },						{							path = "arg", msg = " has unexpected value  . The accepted values are:  ", },					}				}			},		},	}, }

return p