Files
2026-07-13 12:32:21 +08:00

1041 lines
26 KiB
Lua

--- A schema for validating schemas
-- @module kong.db.schema.metaschema
local Schema = require "kong.db.schema"
local json_lib = require "kong.db.schema.json"
local constants = require "kong.constants"
local setmetatable = setmetatable
local assert = assert
local insert = table.insert
local pairs = pairs
local find = string.find
local type = type
local next = next
local keys = require("pl.tablex").keys
local values = require("pl.tablex").values
local sub = string.sub
local fmt = string.format
local match_list = {
type = "array",
elements = {
type = "record",
fields = {
{ pattern = { type = "string", required = true } },
{ err = { type = "string" } },
}
}
}
local match_any_list = {
type = "record",
fields = {
{ patterns = {
type = "array",
elements = { type = "string" },
required = true
} },
{ err = { type = "string" } },
}
}
-- Field attributes which match a validator function in the Schema class
local validators = {
{ between = { type = "array", elements = { type = "number" }, len_eq = 2 }, },
{ eq = { type = "any" }, },
{ ne = { type = "any" }, },
{ gt = { type = "number" }, },
{ len_eq = { type = "integer" }, },
{ len_min = { type = "integer" }, },
{ len_max = { type = "integer" }, },
{ match = { type = "string" }, },
{ not_match = { type = "string" }, },
{ match_all = match_list },
{ match_none = match_list },
{ match_any = match_any_list },
{ starts_with = { type = "string" }, },
{ one_of = { type = "array", elements = { type = "any" } }, },
{ not_one_of = { type = "array", elements = { type = "any" } }, },
{ contains = { type = "any" }, },
{ is_regex = { type = "boolean" }, },
{ timestamp = { type = "boolean" }, },
{ uuid = { type = "boolean" }, },
{ custom_validator = { type = "function" }, },
{ mutually_exclusive_subsets = { type = "array", elements = { type = "array", elements = { type = "string" } } } },
}
-- JSON schema is supported in two different methods:
--
-- * inline: the JSON schema is defined in the field itself
-- * dynamic/reference: the JSON schema is stored in the database
--
-- Inline schemas have the JSON schema definied statically within
-- the typedef's `json_schema.inline` field. Example:
--
-- ```lua
-- local field = {
-- type = "json",
-- json_schema = {
-- inline = {
-- type = "object",
-- properties = {
-- foo = { type = "string" },
-- },
-- },
-- }
-- }
--
-- local record = {
-- type = "record",
-- fields = {
-- { name = { type = "string" } },
-- { config = field },
-- },
-- }
--
-- ```
--
-- Fields with dynamic schemas function similarly to Lua subschemas, wherein
-- the contents of the input are used to generate a string key that is used
-- to lookup the schema from the schema storage. Example:
--
-- ```lua
-- local record = {
-- type = "record",
-- fields = {
-- { name = { type = "string" } },
-- { config = {
-- type = "json",
-- json_schema = {
-- namespace = "my-record-type",
-- parent_subschema_key = "name",
-- optional = true,
-- },
-- },
-- },
-- },
-- }
-- ```
--
-- In this case, an input value of `{ name = "foo", config = "foo config" }`
-- will cause the validation engine to lookup a schema by the name of
-- `my-record-type/foo`. The `optional` field determines what will happen if
-- the schema does not exist. When `optional` is `false`, a missing schema
-- means that input validation will fail. When `optional` is `true`, the input
-- is always accepted.
--
-- Schemas which use this dynamic reference format can also optionally supply
-- a default inline schema, which will be evaluated when the dynamic schema
-- does not exist:
--
-- ```lua
-- local record = {
-- type = "record",
-- fields = {
-- { name = { type = "string" } },
-- { config = {
-- type = "json",
-- json_schema = {
-- namespace = "my-record-type",
-- parent_subschema_key = "name",
-- default = {
-- { type = { "string", "null" } },
-- },
-- },
-- },
-- },
-- },
-- }
-- ```
--
local json_metaschema = {
type = "record",
fields = {
{ namespace = { type = "string", one_of = values(constants.SCHEMA_NAMESPACES), }, },
{ parent_subschema_key = { type = "string" }, },
{ optional = { type = "boolean", }, },
{ inline = { type = "any", custom_validator = json_lib.validate_schema, }, },
{ default = { type = "any", custom_validator = json_lib.validate_schema, }, },
},
entity_checks = {
{ at_least_one_of = { "inline", "namespace", "parent_subschema_key" }, },
{ mutually_required = { "namespace", "parent_subschema_key" }, },
{ mutually_exclusive_sets = {
set1 = { "inline" },
set2 = { "namespace", "parent_subschema_key", "optional" },
},
},
},
}
-- Other field attributes, that do not correspond to validators
local field_schema = {
{ type = { type = "string", one_of = keys(Schema.valid_types), required = true }, },
{ required = { type = "boolean" }, },
{ reference = { type = "string" }, },
{ description = { type = "string", len_min = 10, len_max = 500}, },
{ examples = { type = "array", elements = { type = "any" } } },
{ auto = { type = "boolean" }, },
{ unique = { type = "boolean" }, },
{ unique_across_ws = { type = "boolean" }, },
{ on_delete = { type = "string", one_of = { "restrict", "cascade", "null" } }, },
{ default = { type = "self" }, },
{ abstract = { type = "boolean" }, },
{ generate_admin_api = { type = "boolean" }, },
{ immutable = { type = "boolean" }, },
{ err = { type = "string" } },
{ encrypted = { type = "boolean" }, },
{ referenceable = { type = "boolean" }, },
{ json_schema = json_metaschema },
-- Transient attribute: used to mark a field as a non-db column
{ transient = { type = "boolean" }, },
-- Deprecation attribute: used to mark a field as deprecated
-- Results in `message` and `removal_in_version` to be printed in a warning
-- (via kong.deprecation) when the field is used.
-- If `old_default` is not set, the warning message is always printed.
-- If `old_default` is set, the warning message is only printed when the
-- field's value is different from the value of `old_default`.
{ deprecation = {
type = "record",
fields = {
{ message = { type = "string", required = true } },
{ removal_in_version = { type = "string", required = true } },
{ old_default = { type = "any", required = false } },
{ replaced_with = { type = "array", required = false,
elements = { type = "record",
required = false,
fields = {
{ path = { type = "array", len_min = 1, required = true, elements = { type = "string"}} },
{ reverse_mapping_function = { type = "function", required = false }}
},
}
} },
},
} },
}
for i = 1, #validators do
insert(field_schema, validators[i])
end
-- Most of the above are optional
for i = 1, #field_schema do
local field = field_schema[i]
local data = field[next(field)]
data.nilable = not data.required
end
local field_entity_checks = {
-- if 'unique_across_ws' is set, then 'unique' must be set too
{
conditional = {
if_field = "unique_across_ws", if_match = { eq = true },
then_field = "unique", then_match = { eq = true, required = true },
}
},
}
local fields_array = {
type = "array",
elements = {
type = "map",
keys = { type = "string" },
values = { type = "record", fields = field_schema, entity_checks = field_entity_checks },
required = true,
len_eq = 1,
},
}
local transformations_array = {
type = "array",
nilable = true,
elements = {
type = "record",
fields = {
{
input = {
type = "array",
required = false,
elements = {
type = "string"
},
},
},
{
needs = {
type = "array",
required = false,
elements = {
type = "string"
},
}
},
{
on_write = {
type = "function",
required = false,
},
},
{
on_read = {
type = "function",
required = false,
},
},
},
entity_checks = {
{
at_least_one_of = {
"on_write",
"on_read",
},
},
},
},
}
-- Recursive field attributes
insert(field_schema, { elements = { type = "record", fields = field_schema } })
insert(field_schema, { keys = { type = "record", fields = field_schema } })
insert(field_schema, { values = { type = "record", fields = field_schema } })
insert(field_schema, { fields = fields_array })
local conditional_validators = {
{ required = { type = "boolean" } },
{ elements = { type = "record", fields = field_schema } },
{ keys = { type = "record", fields = field_schema } },
{ values = { type = "record", fields = field_schema } },
}
for i = 1, #validators do
insert(conditional_validators, validators[i])
end
local entity_checkers = {
{ at_least_one_of = { type = "array", elements = { type = "string" } } },
{ conditional_at_least_one_of = {
type = "record",
fields = {
{ if_field = { type = "string" } },
{ if_match = { type = "record", fields = conditional_validators } },
{ then_at_least_one_of = { type = "array", elements = { type = "string" } } },
{ then_err = { type = "string" } },
{ else_match = { type = "record", fields = conditional_validators } },
{ else_then_at_least_one_of = { type = "array", elements = { type = "string" } } },
{ else_then_err = { type = "string" } },
},
},
},
{ only_one_of = { type = "array", elements = { type = "string" } } },
{ distinct = { type = "array", elements = { type = "string" } }, },
{ conditional = {
type = "record",
fields = {
{ if_field = { type = "string" } },
{ if_match = { type = "record", fields = conditional_validators } },
{ then_field = { type = "string" } },
{ then_match = { type = "record", fields = conditional_validators } },
{ then_err = { type = "string" } },
},
},
},
{ custom_entity_check = {
type = "record",
fields = {
{ field_sources = { type = "array", elements = { type = "string" } } },
{ fn = { type = "function" } },
{ run_with_missing_fields = { type = "boolean" } },
{ run_with_invalid_fields = { type = "boolean" } },
}
}
},
{ mutually_required = { type = "array", elements = { type = "string" } } },
{ mutually_exclusive = { type = "array", elements = { type = "string" } } },
{ mutually_exclusive_sets = {
type = "record",
fields = {
{ set1 = {type = "array", elements = {type = "string"} } },
{ set2 = {type = "array", elements = {type = "string"} } },
}
}
},
}
local entity_check_names = {}
for i = 1, #entity_checkers do
local field = entity_checkers[i]
local name = next(field)
--field[name].nilable = true
insert(entity_check_names, name)
end
local entity_checks_schema = {
type = "array",
elements = {
type = "record",
fields = entity_checkers,
entity_checks = {
{ only_one_of = keys(Schema.entity_checkers) }
}
},
nilable = true,
}
local shorthand_fields_array = {
type = "array",
elements = {
type = "map",
keys = { type = "string" },
-- values are defined below after field_schema definition is complete
required = true,
len_eq = 1,
},
nilable = true,
}
insert(field_schema, { entity_checks = entity_checks_schema })
insert(field_schema, { shorthand_fields = shorthand_fields_array })
local meta_errors = {
ATTRIBUTE = "field of type '%s' cannot have attribute '%s'",
REQUIRED = "field of type '%s' must declare '%s'",
TABLE = "'%s' must be a table",
BOOLEAN = "'%s' must be a boolean",
TYPE = "missing type declaration",
FIELD_EMPTY = "field entry table is empty",
FIELDS_ARRAY = "each entry in fields must be a sub-table",
FIELDS_KEY = "each key in fields must be a string",
ENDPOINT_KEY = "value must be a field name",
CACHE_KEY = "values must be field names",
CACHE_KEY_UNIQUE = "a field used as a single cache key must be unique",
TTL_RESERVED = "ttl is a reserved field name when ttl is enabled",
SUBSCHEMA_KEY = "value must be a field name",
SUBSCHEMA_KEY_TYPE = "must be a string or set field",
JSON_PARENT_KEY = "value must be a field name of the parent schema",
JSON_PARENT_KEY_TYPE = "value must be a string field of the parent schema",
}
local required_attributes = {
array = { "elements" },
set = { "elements" },
map = { "keys", "values" },
record = { "fields" },
json = { "json_schema" },
}
local attribute_types = {
between = {
["integer"] = true,
["number"] = true,
},
len_eq = {
["array"] = true,
["set"] = true,
["string"] = true,
["map"] = true,
},
match = {
["string"] = true,
},
one_of = {
["string"] = true,
["number"] = true,
["integer"] = true,
},
contains = {
["array"] = true,
["set"] = true,
},
is_regex = {
["string"] = true,
},
timestamp = {
["number"] = true,
["integer"] = true,
},
uuid = {
["string"] = true,
},
unique = {
["string"] = true,
["number"] = true,
["integer"] = true,
["foreign"] = true,
},
unique_across_ws = {
["string"] = true,
["number"] = true,
["integer"] = true,
["foreign"] = true,
},
abstract = {
["string"] = true,
["number"] = true,
["integer"] = true,
["record"] = true,
["array"] = true,
["set"] = true,
["map"] = true,
},
json_schema = {
["json"] = true,
},
transient = {
["string"] = true,
["number"] = true,
["integer"] = true,
["array"] = true,
["set"] = true,
["boolean"] = true,
},
}
local nested_attributes = {
["elements" ] = true,
["keys" ] = true,
["values" ] = true,
}
local check_field
local function has_schema_field(schema, name)
if schema == nil then
return false
end
local fields = schema.fields
local fields_count = #fields
local dot = find(name, ".", 1, true)
if not dot then
for i = 1, fields_count do
local field = fields[i]
local k = next(field)
if k == name then
return true
end
end
return false
end
local hd, tl = sub(name, 1, dot - 1), sub(name, dot + 1)
for i = 1, fields_count do
local field = fields[i]
local k = next(field)
if k == hd then
if field[hd] and field[hd].type == "foreign" then
-- metaschema has no access to foreign schemas
-- so we just trust the developer of the schema.
return true
end
return has_schema_field(field[hd], tl)
end
end
return false
end
local check_fields = function(schema, errors)
local transformations = schema.transformations
if transformations then
for i = 1, #transformations do
local transformation = transformations[i]
if transformation.input then
for j = 1, #transformation.input do
local input = transformation.input[j]
if not has_schema_field(schema, input) then
errors.transformations = errors.transformations or {}
errors.transformations.input = errors.transformations.input or {}
errors.transformations.input[i] = errors.transformations.input[i] or {}
errors.transformations.input[i][j] = fmt("invalid field name: %s", input)
end
end
end
if transformation.needs then
for j = 1, #transformation.needs do
local need = transformation.needs[j]
if not has_schema_field(schema, need) then
errors.transformations = errors.transformations or {}
errors.transformations.needs = errors.transformations.needs or {}
errors.transformations.needs[i] = errors.transformations.needs[i] or {}
errors.transformations.needs[i][j] = fmt("invalid field name: %s", need)
end
end
end
end
end
for i = 1, #schema.fields do
local item = schema.fields[i]
if type(item) ~= "table" then
errors["fields"] = meta_errors.FIELDS_ARRAY
break
end
local k = next(item)
if not k then
errors["fields"] = meta_errors.FIELD_EMPTY
break
end
local field = item[k]
if type(field) == "table" then
check_field(k, field, errors, schema)
else
errors[k] = meta_errors.TABLE:format(k)
end
end
if next(errors) then
return nil, errors
end
return true
end
check_field = function(k, field, errors, parent_schema)
if not field.type then
errors[k] = meta_errors.TYPE
return nil
end
if required_attributes[field.type] then
local req_attrs = required_attributes[field.type]
if field.abstract and field.type == "record" then
req_attrs = {}
end
for i = 1, #req_attrs do
local required = req_attrs[i]
if not field[required] then
errors[k] = meta_errors.REQUIRED:format(field.type, required)
end
end
end
for attr, _ in pairs(field) do
if attribute_types[attr] and not attribute_types[attr][field.type] then
errors[k] = meta_errors.ATTRIBUTE:format(field.type, attr)
end
end
for name, _ in pairs(nested_attributes) do
if field[name] then
if type(field[name]) == "table" then
check_field(k, field[name], errors, field)
else
errors[k] = meta_errors.TABLE:format(name)
end
end
end
if field.type == "json"
and field.json_schema
and field.json_schema.parent_subschema_key
then
local parent_subschema_key = field.json_schema.parent_subschema_key
local found = false
for i = 1, #parent_schema.fields do
local item = parent_schema.fields[i]
local parent_field_name = next(item)
local parent_field = item[parent_field_name]
if parent_subschema_key == parent_field_name then
if parent_field.type ~= "string" then
errors[k] = errors[k] or {}
errors[k].json_schema = {
parent_subschema_key = meta_errors.JSON_PARENT_KEY_TYPE
}
end
found = true
break
end
end
if not found then
errors[k] = errors[k] or {}
errors[k].json_schema = {
parent_subschema_key = meta_errors.JSON_PARENT_KEY
}
return
end
end
if field.fields then
return check_fields(field, errors)
end
end
-- Build a variant of the field_schema, adding a 'func' attribute
-- and restricting the set of valid types.
local function make_shorthand_field_schema()
local shorthand_field_schema = {}
for k, v in pairs(field_schema) do
shorthand_field_schema[k] = v
end
-- do not accept complex/recursive types
-- which require additional schema processing as shorthands
local invalid_as_shorthand = {
record = true,
foreign = true,
["function"] = true,
}
local shorthand_field_types = {}
for k in pairs(Schema.valid_types) do
if not invalid_as_shorthand[k] then
insert(shorthand_field_types, k)
end
end
assert(next(shorthand_field_schema[1]) == "type")
shorthand_field_schema[1] = { type = { type = "string", one_of = shorthand_field_types, required = true }, }
insert(shorthand_field_schema, { func = { type = "function", required = true } })
insert(shorthand_field_schema, { translate_backwards = { type = "array", elements = { type = "string" }, required = false } })
return shorthand_field_schema
end
shorthand_fields_array.elements.values = {
type = "record",
fields = make_shorthand_field_schema(),
entity_checks = field_entity_checks
}
local MetaSchema = Schema.new({
name = "metaschema",
fields = {
{
name = {
type = "string",
required = true
},
},
{
primary_key = {
type = "array",
elements = { type = "string" },
required = true,
},
},
{
workspaceable = {
type = "boolean",
nilable = true
},
},
{
endpoint_key = {
type = "string",
nilable = true,
},
},
{
cache_key = {
type = "array",
elements = {
type = "string",
},
nilable = true,
},
},
{
ttl = {
type = "boolean",
nilable = true,
}
},
{
db_export = {
type = "boolean",
nilable = true,
default = true,
}
},
{
subschema_key = {
type = "string",
nilable = true,
},
},
{
subschema_error = {
type = "string",
nilable = true,
},
},
{
generate_admin_api = {
type = "boolean",
nilable = true,
default = true,
},
},
{
admin_api_name = {
type = "string",
nilable = true,
},
},
{
table_name = {
type = "string",
nilable = true,
},
},
{
admin_api_nested_name = {
type = "string",
nilable = true,
},
},
{
fields = fields_array,
},
{
entity_checks = entity_checks_schema,
},
{
shorthand_fields = shorthand_fields_array,
},
{
transformations = transformations_array,
},
{
check = {
type = "function",
nilable = true,
},
},
{
dao = {
type = "string",
nilable = true
},
},
},
check = function(schema)
local errors = {}
local fields = schema.fields
if not fields then
errors["fields"] = meta_errors.TABLE:format("fields")
return nil, errors
end
if not schema.table_name then
schema.table_name = schema.name
end
if schema.endpoint_key then
local found = false
for i = 1, #fields do
local k = next(fields[i])
if schema.endpoint_key == k then
found = true
break
end
end
if not found then
errors["endpoint_key"] = meta_errors.ENDPOINT_KEY
end
end
local cache_key = schema.cache_key
if cache_key then
local found
for i = 1, #cache_key do
found = nil
for j = 1, #fields do
local item = fields[j]
local k = next(item)
if cache_key[i] == k then
found = item[k]
break
end
end
if not found then
errors["cache_key"] = meta_errors.CACHE_KEY
break
end
end
if #cache_key == 1 then
if found and not found.unique then
errors["cache_key"] = meta_errors.CACHE_KEY_UNIQUE
end
end
end
if schema.subschema_key then
local found = false
for i = 1, #fields do
local item = fields[i]
local k = next(item)
local field = item[k]
if schema.subschema_key == k then
if field.type ~= "string" and field.type ~= "set" then
errors["subschema_key"] = meta_errors.SUBSCHEMA_KEY_TYPE
end
found = true
break
end
end
if not found then
errors["subschema_key"] = meta_errors.SUBSCHEMA_KEY
end
end
if schema.ttl then
for i = 1, #fields do
local k = next(fields[i])
if k == "ttl" then
errors["ttl"] = meta_errors.TTL_RESERVED
break
end
end
end
local transformations = schema.transformations
if transformations then
for i = 1, #transformations do
local input = transformations[i].input
if input then
for j = 1, #input do
if not has_schema_field(schema, input[j]) then
if not errors.transformations then
errors.transformations = {}
end
if not errors.transformations.input then
errors.transformations.input = {}
end
if not errors.transformations.input[i] then
errors.transformations.input[i] = {}
end
errors.transformations.input[i][j] = fmt("invalid field name: %s", input)
end
end
end
local needs = transformations[i].needs
if needs then
for j = 1, #needs do
if not has_schema_field(schema, needs[j]) then
if not errors.transformations then
errors.transformations = {}
end
if not errors.transformations.needs then
errors.transformations.needs = {}
end
if not errors.transformations.needs[i] then
errors.transformations.needs[i] = {}
end
errors.transformations.needs[i][j] = fmt("invalid field name: %s", needs[j])
end
end
end
end
end
return check_fields(schema, errors)
end,
})
MetaSchema.valid_types = setmetatable({
["function"] = true,
}, { __index = Schema.valid_types })
--- Produce a list of validators understood by the MetaSchema.
-- This list is produced from the MetaSchema definition and
-- is used for cross-checking against the Schema validators.
-- @return a set of validator names.
function MetaSchema.get_supported_validator_set()
local set = {}
for i = 1, #validators do
local name = next(validators[i])
set[name] = true
end
return set
end
MetaSchema.MetaSubSchema = Schema.new({
name = "metasubschema",
fields = {
{
name = {
type = "string",
required = true,
},
},
{
fields = fields_array,
},
{
entity_checks = entity_checks_schema,
},
{
shorthand_fields = shorthand_fields_array,
},
{
transformations = transformations_array,
},
{
check = {
type = "function",
nilable = true,
},
},
},
check = function(schema)
local errors = {}
if not schema.fields then
errors["fields"] = meta_errors.TABLE:format("fields")
return nil, errors
end
return check_fields(schema, errors)
end,
})
return MetaSchema