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.init(schemas, schemaName) return h.validate(schema, data, dataName) end

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

h.references = {}

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

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

function h.resolveReferences(schemaNode) if schemaNode._ref then local resolvedSchema = utilsTable.merge({}, h.references[schemaNode._ref], schemaNode) utilsTable.merge(schemaNode, resolvedSchema) 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			-- h.mergeAllOfs(schemaNode)			-- return false		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) local rawType if schema._showDoc == false and schema._ref then rawType = string.gsub(schema._ref, "#/", "") elseif schema.oneOf then rawType = p.TYPES.oneOf 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 {} local keys, subtype = h.getTypeDefs(subschema.label or "", subschema, formattingFn, schema) if keys and #keys > 0 then table.insert(subkeys, keys) end if i == 1 then symbolicType = subtype else symbolicType = string.format(SYMBOLS.oneOf, symbolicType, subtype) end end subkeys = utilsTable.uniqueBy(utilsTable.isEqual)(subkeys) -- for situations like record|{record} where the subtypes are the same end end symbolicType = symbolicType or rawType local parentType if parentSchema then parentType = parentSchema.oneOf and p.TYPES.oneOf or parentSchema.type 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	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)		h.resolveReferences(schemaNode)		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.Schemas = { Schema = { allOf = { {				type = "record", properties = { {						name = "required", type = "boolean", desc = "If, the value cannot be  ." },					{						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 = "_id", type = "string", desc = "An ID to use for referencing." },				},			},			{				oneOf = { { _ref = "#/primitive", label = "Primitive Schema", }, { _ref = "#/array", label = "Array Schema", }, { _ref = "#/record", label = "Record Schema", }, { _ref = "#/map", label = "Map Schema" }, },			}		},	},	primitive = { type = "record", properties = { {				name = "type", type = "string", required = true, enum = { "string", "number", "boolean" }, desc = ', , or   (table types are also possible - see below).' },			{				name = "enum", type = "array", items = { type = "any" }, desc = "An array of values that are considered acceptable.", },		},	},	array = { type = "record", properties = { {				name = "type", type = "string", required = true, enum = { "array" }, desc = 'The string .' },			{				name = "items", required = true, _ref = "#/Schema", _showDoc = false, desc = "A schema that all items in the array must adhere to.", },		},	},	record = { type = "record", properties = { {				name = "type", type = "string", required = true, enum = { "record" }, desc = 'The string .' },			{				name = "properties", type = "array", required = true, 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, desc = "The schema that the value of the record entry must adhere to.", }							}						},					},				},			},		}	},	map = { type = "record", properties = { {				name = "type", type = "string", required = true, enum = { "array" }, 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 = { "💩" }, },					"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