2692 lines
76 KiB
Lua
2692 lines
76 KiB
Lua
local pretty = require "pl.pretty"
|
|
local cjson = require "cjson"
|
|
local new_tab = require "table.new"
|
|
local nkeys = require "table.nkeys"
|
|
local is_reference = require "kong.pdk.vault".is_reference
|
|
local json = require "kong.db.schema.json"
|
|
local cjson_safe = require "cjson.safe"
|
|
local deprecation = require "kong.deprecation"
|
|
|
|
|
|
local compare_no_order = require "pl.tablex".compare_no_order
|
|
local deepcompare = require "pl.tablex".deepcompare
|
|
|
|
|
|
local cycle_aware_deep_copy = require "kong.tools.table".cycle_aware_deep_copy
|
|
local table_merge = require "kong.tools.table".table_merge
|
|
local null_aware_table_merge = require "kong.tools.table".null_aware_table_merge
|
|
local table_path = require "kong.tools.table".table_path
|
|
local is_array = require "kong.tools.table".is_array
|
|
local join_string = require "kong.tools.string".join
|
|
|
|
|
|
local setmetatable = setmetatable
|
|
local getmetatable = getmetatable
|
|
local re_match = ngx.re.match
|
|
local re_find = ngx.re.find
|
|
local tostring = tostring
|
|
local concat = table.concat
|
|
local insert = table.insert
|
|
local format = string.format
|
|
local ipairs = ipairs
|
|
local unpack = unpack
|
|
local assert = assert
|
|
local yield = require("kong.tools.yield").yield
|
|
local pairs = pairs
|
|
local pcall = pcall
|
|
local floor = math.floor
|
|
local type = type
|
|
local next = next
|
|
local update_time = ngx.update_time
|
|
local ngx_time = ngx.time
|
|
local ngx_now = ngx.now
|
|
local find = string.find
|
|
local null = ngx.null
|
|
local max = math.max
|
|
local sub = string.sub
|
|
local safe_decode = cjson_safe.decode
|
|
|
|
|
|
local random_string = require("kong.tools.rand").random_string
|
|
local uuid = require("kong.tools.uuid").uuid
|
|
local json_validate = json.validate
|
|
|
|
local EMPTY = {}
|
|
|
|
local Schema = {}
|
|
Schema.__index = Schema
|
|
|
|
|
|
local _cache = {}
|
|
local _workspaceable = {}
|
|
|
|
|
|
local validation_errors = {
|
|
-- general message
|
|
ERROR = "Validation error: %s",
|
|
-- types
|
|
ARRAY = "expected an array",
|
|
SET = "expected a set",
|
|
MAP = "expected a map",
|
|
RECORD = "expected a record",
|
|
STRING = "expected a string",
|
|
NUMBER = "expected a number",
|
|
BOOLEAN = "expected a boolean",
|
|
INTEGER = "expected an integer",
|
|
FUNCTION = "expected a function",
|
|
-- validations
|
|
EQ = "value must be %s",
|
|
NE = "value must not be %s",
|
|
GT = "value must be greater than %s",
|
|
BETWEEN = "value should be between %d and %d",
|
|
LEN_EQ = "length must be %d",
|
|
LEN_MIN = "length must be at least %d",
|
|
LEN_MAX = "length must be at most %d",
|
|
MATCH = "invalid value: %s",
|
|
NOT_MATCH = "invalid value: %s",
|
|
MATCH_ALL = "invalid value: %s",
|
|
MATCH_NONE = "invalid value: %s",
|
|
MATCH_ANY = "invalid value: %s",
|
|
STARTS_WITH = "should start with: %s",
|
|
CONTAINS = "expected to contain: %s",
|
|
ONE_OF = "expected one of: %s",
|
|
NOT_ONE_OF = "must not be one of: %s",
|
|
IS_REGEX = "not a valid regex: %s",
|
|
TIMESTAMP = "expected a valid timestamp",
|
|
UUID = "expected a valid UUID",
|
|
VALIDATION = "failed validating: %s",
|
|
-- field presence
|
|
BAD_INPUT = "expected an input table",
|
|
REQUIRED = "required field missing",
|
|
NO_FOREIGN_DEFAULT = "will not generate a default value for a foreign field",
|
|
UNKNOWN = "unknown field",
|
|
IMMUTABLE = "immutable field cannot be updated",
|
|
-- entity checks
|
|
REQUIRED_FOR_ENTITY_CHECK = "field required for entity check",
|
|
ENTITY_CHECK = "failed entity check: %s(%s)",
|
|
ENTITY_CHECK_N_FIELDS = "entity check requires %d fields",
|
|
CHECK = "entity check failed",
|
|
CONDITIONAL = "failed conditional validation given value of field '%s'",
|
|
AT_LEAST_ONE_OF = "at least one of these fields must be non-empty: %s",
|
|
CONDITIONAL_AT_LEAST_ONE_OF = "at least one of these fields must be non-empty: %s",
|
|
ONLY_ONE_OF = "exactly one of these fields must be non-empty: %s",
|
|
DISTINCT = "values of these fields must be distinct: %s",
|
|
MUTUALLY_REQUIRED = "all or none of these fields must be set: %s",
|
|
MUTUALLY_EXCLUSIVE = "only one or none of these fields must be set: %s",
|
|
MUTUALLY_EXCLUSIVE_SETS = "these sets are mutually exclusive: %s",
|
|
-- schema error
|
|
SCHEMA_NO_DEFINITION = "expected a definition table",
|
|
SCHEMA_NO_FIELDS = "error in schema definition: no 'fields' table",
|
|
SCHEMA_BAD_REFERENCE = "schema refers to an invalid foreign entity: %s",
|
|
SCHEMA_CANNOT_VALIDATE = "error in schema prevents from validating this field",
|
|
SCHEMA_TYPE = "invalid type: %s",
|
|
-- primary key errors
|
|
NOT_PK = "not a primary key",
|
|
MISSING_PK = "missing primary key",
|
|
-- subschemas
|
|
SUBSCHEMA_UNKNOWN = "unknown type: %s",
|
|
SUBSCHEMA_BAD_PARENT = "error in definition of '%s': entities of type '%s' cannot have subschemas",
|
|
SUBSCHEMA_UNDEFINED_FIELD = "error in definition of '%s': %s: abstract field was not specialized",
|
|
SUBSCHEMA_BAD_TYPE = "error in definition of '%s': %s: cannot change type in a specialized field",
|
|
SUBSCHEMA_BAD_FIELD = "error in definition of '%s': %s: cannot create a new field",
|
|
SUBSCHEMA_ABSTRACT_FIELD = "error in schema definition: abstract field was not specialized",
|
|
-- transformations
|
|
TRANSFORMATION_ERROR = "transformation failed: %s",
|
|
-- json
|
|
JSON_ENCODE_ERROR = "value could not be JSON-encoded: %s",
|
|
JSON_DECODE_ERROR = "value could not be JSON-decoded: %s",
|
|
JSON_SCHEMA_ERROR = "value failed JSON-schema validation: %s",
|
|
JSON_PARENT_KEY_MISSING = "validation of %s depends on the parent attribute %s, but it is not set",
|
|
JSON_SCHEMA_NOT_FOUND = "mandatory json schema for field (%s) not found"
|
|
}
|
|
|
|
|
|
Schema.valid_types = {
|
|
array = true,
|
|
set = true,
|
|
string = true,
|
|
number = true,
|
|
boolean = true,
|
|
integer = true,
|
|
foreign = true,
|
|
map = true,
|
|
record = true,
|
|
["function"] = true,
|
|
json = true,
|
|
}
|
|
|
|
|
|
local uuid_regex = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
|
|
|
|
local function make_length_validator(err, fn)
|
|
return function(value, n, field)
|
|
local len = #value
|
|
if field.type == "map" then
|
|
for _ in pairs(value) do
|
|
len = len + 1
|
|
end
|
|
end
|
|
if fn(len, n) then
|
|
return true
|
|
end
|
|
return nil, validation_errors[err]:format(n)
|
|
end
|
|
end
|
|
|
|
|
|
--- Produce a nicely quoted list:
|
|
-- Given `{"foo", "bar", "baz"}` and `"or"`, produces
|
|
-- `"'foo', 'bar', 'baz'"`.
|
|
-- Given an array of arrays (e.g., `{{"f1", "f2"}, {"f3", "f4"}}`), produces
|
|
-- `"('f1', 'f2'), ('f3', 'f4')"`.
|
|
-- @param words an array of strings and/or arrays of strings.
|
|
-- @return The string of quoted words and/or arrays.
|
|
local function quoted_list(words)
|
|
local msg = {}
|
|
for i = 1, #words do
|
|
local word = words[i]
|
|
if type(word) == "table" then
|
|
insert(msg, ("(%s)"):format(quoted_list(word)))
|
|
else
|
|
insert(msg, ("'%s'"):format(word))
|
|
end
|
|
end
|
|
return concat(msg, ", ")
|
|
end
|
|
|
|
|
|
--- Validator functions available for schemas.
|
|
-- A validator can only affect one field.
|
|
-- Each validation is registered in a schema field definition as
|
|
-- a key-value pair. The key is the validation name and the value
|
|
-- is an optional argument (by convention, `true` if the argument
|
|
-- is to be ignored). Each validation function takes as inputs
|
|
-- the value to be validated and the argument given in the schema
|
|
-- definition. The function should return true or nil,
|
|
-- optionally followed by an error message. If the error message
|
|
-- is not given, the validation name (in uppercase) is used as
|
|
-- a key in `validation_errors` to get a message. If it was not
|
|
-- registered, a generic fallback error message is produced with
|
|
-- `validation_errors.VALIDATION`.
|
|
Schema.validators = {
|
|
|
|
between = function(value, limits)
|
|
if value < limits[1] or value > limits[2] then
|
|
return nil, validation_errors.BETWEEN:format(limits[1], limits[2])
|
|
end
|
|
return true
|
|
end,
|
|
|
|
eq = function(value, wanted)
|
|
if value == wanted then
|
|
return true
|
|
end
|
|
local str = (wanted == null) and "null" or tostring(wanted)
|
|
return nil, validation_errors.EQ:format(str)
|
|
end,
|
|
|
|
ne = function(value, wanted)
|
|
if value ~= wanted then
|
|
return true
|
|
end
|
|
local str = (wanted == null) and "null" or tostring(value)
|
|
return nil, validation_errors.NE:format(str)
|
|
end,
|
|
|
|
gt = function(value, limit)
|
|
if value > limit then
|
|
return true
|
|
end
|
|
return nil, validation_errors.GT:format(tostring(limit))
|
|
end,
|
|
|
|
len_eq = make_length_validator("LEN_EQ", function(len, n)
|
|
return len == n
|
|
end),
|
|
|
|
len_min = make_length_validator("LEN_MIN", function(len, n)
|
|
return len >= n
|
|
end),
|
|
|
|
len_max = make_length_validator("LEN_MAX", function(len, n)
|
|
return len <= n
|
|
end),
|
|
|
|
match = function(value, pattern)
|
|
local m = value:match(pattern)
|
|
if not m then
|
|
return nil, validation_errors.MATCH:format(value)
|
|
end
|
|
return true
|
|
end,
|
|
|
|
is_regex = function(value)
|
|
local _, err = re_match("any string", value)
|
|
return err == nil
|
|
end,
|
|
|
|
not_match = function(value, pattern)
|
|
local m = value:match(pattern)
|
|
if m then
|
|
return nil, validation_errors.NOT_MATCH:format(value)
|
|
end
|
|
return true
|
|
end,
|
|
|
|
match_all = function(value, list)
|
|
for i = 1, #list do
|
|
local m = value:match(list[i].pattern)
|
|
if not m then
|
|
return nil, list[i].err
|
|
end
|
|
end
|
|
return true
|
|
end,
|
|
|
|
match_none = function(value, list)
|
|
for i = 1, #list do
|
|
local m = value:match(list[i].pattern)
|
|
if m then
|
|
return nil, list[i].err
|
|
end
|
|
end
|
|
return true
|
|
end,
|
|
|
|
match_any = function(value, arg)
|
|
local patterns = arg.patterns
|
|
for i = 1, #patterns do
|
|
local m = value:match(patterns[i])
|
|
if m then
|
|
return true
|
|
end
|
|
end
|
|
return nil, arg.err
|
|
end,
|
|
|
|
starts_with = function(value, expected)
|
|
-- produces simpler error messages than 'match'
|
|
if sub(value, 1, #expected) ~= expected then
|
|
return nil, validation_errors.STARTS_WITH:format(expected)
|
|
end
|
|
return true
|
|
end,
|
|
|
|
one_of = function(value, options)
|
|
for i = 1, #options do
|
|
if value == options[i] then
|
|
return true
|
|
end
|
|
end
|
|
return nil, validation_errors.ONE_OF:format(concat(options, ", "))
|
|
end,
|
|
|
|
not_one_of = function(value, options)
|
|
for i = 1, #options do
|
|
if value == options[i] then
|
|
return nil, validation_errors.NOT_ONE_OF:format(concat(options, ", "))
|
|
end
|
|
end
|
|
return true
|
|
end,
|
|
|
|
timestamp = function(value)
|
|
return value > 0 or nil
|
|
end,
|
|
|
|
uuid = function(value)
|
|
if #value ~= 36 then
|
|
return nil
|
|
end
|
|
return re_find(value, uuid_regex, "ioj") and true or nil
|
|
end,
|
|
|
|
contains = function(array, wanted)
|
|
for i = 1, #array do
|
|
if array[i] == wanted then
|
|
return true
|
|
end
|
|
end
|
|
|
|
return nil, validation_errors.CONTAINS:format(wanted)
|
|
end,
|
|
|
|
mutually_exclusive_subsets = function(value, subsets)
|
|
local subset_union = {} -- union of all subsets; key is an element, value is the
|
|
for i = 1, #subsets do -- the subset the element is part of
|
|
local subset = subsets[i]
|
|
for j = 1, #subset do
|
|
subset_union[subset[j]] = subset
|
|
end
|
|
end
|
|
|
|
local member_of = {}
|
|
|
|
for i = 1, #value do -- for each value, add the set it's part of
|
|
local val = value[i]
|
|
if subset_union[val] and not member_of[subset_union[val]] then -- to member_of, iff it hasn't already
|
|
member_of[subset_union[val]] = true
|
|
member_of[#member_of+1] = subset_union[val]
|
|
end
|
|
end
|
|
|
|
if #member_of <= 1 then
|
|
return true
|
|
else
|
|
return nil, validation_errors.MUTUALLY_EXCLUSIVE_SETS:format(quoted_list(member_of))
|
|
end
|
|
end,
|
|
|
|
custom_validator = function(value, fn)
|
|
return fn(value)
|
|
end
|
|
|
|
}
|
|
|
|
|
|
Schema.validators_order = {
|
|
"eq",
|
|
"ne",
|
|
"not_one_of",
|
|
"one_of",
|
|
|
|
-- type-dependent
|
|
"gt",
|
|
"timestamp",
|
|
"uuid",
|
|
"is_regex",
|
|
"between",
|
|
|
|
-- strings (1/2)
|
|
"len_eq",
|
|
"len_min",
|
|
"len_max",
|
|
|
|
-- strings (2/2)
|
|
"starts_with",
|
|
"not_match",
|
|
"match_none",
|
|
"match",
|
|
"match_all",
|
|
"match_any",
|
|
|
|
-- arrays
|
|
"contains",
|
|
|
|
-- other
|
|
"custom_validator",
|
|
"mutually_exclusive_subsets",
|
|
}
|
|
|
|
|
|
--- Returns true if a field is non-empty (with emptiness defined
|
|
-- for strings and aggregate values).
|
|
-- This function is defined as `is_nonempty` rather than the more intuitive
|
|
-- `is_empty` because the former has a less fuzzy definition:
|
|
-- being non-empty clearly excludes null and nil values.
|
|
-- @param value a value, which may be `ngx.null` or `nil`.
|
|
local function is_nonempty(value)
|
|
if value == nil
|
|
or value == null
|
|
or (type(value) == "table" and not next(value))
|
|
or (type(value) == "string" and value == "") then
|
|
return false
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
--- Returns true if a table is a sequence
|
|
-- @param t a table to be checked
|
|
-- @return `true` if `t` is a sequence, otherwise returns false.
|
|
local function is_sequence(t)
|
|
if type(t) ~= "table" then
|
|
return false
|
|
end
|
|
|
|
local m, c = 0, 0
|
|
|
|
for k, _ in pairs(t) do
|
|
c = c + 1
|
|
|
|
if t[c] == nil then
|
|
return false
|
|
end
|
|
|
|
if type(k) ~= "number" or k < 0 or floor(k) ~= k then
|
|
return false
|
|
end
|
|
|
|
m = max(m, k)
|
|
end
|
|
|
|
return c == m
|
|
end
|
|
|
|
|
|
-- Get a field from a possibly-nested table using a string key
|
|
-- such as "x.y.z", such that `get_field(t, "x.y.z")` is the
|
|
-- same as `t.x.y.z`.
|
|
local function get_field(tbl, name)
|
|
if tbl == nil or tbl == null then
|
|
return nil
|
|
end
|
|
local dot = find(name, ".", 1, true)
|
|
if not dot then
|
|
return tbl[name]
|
|
end
|
|
local hd, tl = sub(name, 1, dot - 1), sub(name, dot + 1)
|
|
return get_field(tbl[hd], tl)
|
|
end
|
|
|
|
|
|
-- Set a field from a possibly-nested table using a string key
|
|
-- such as "x.y.z", such that `set_field(t, "x.y.z", v)` is the
|
|
-- same as `t.x.y.z = v`.
|
|
local function set_field(tbl, name, value)
|
|
local dot = find(name, ".", 1, true)
|
|
if not dot then
|
|
tbl[name] = value
|
|
return
|
|
end
|
|
local hd, tl = sub(name, 1, dot - 1), sub(name, dot + 1)
|
|
local subtbl = tbl[hd]
|
|
if subtbl == nil then
|
|
subtbl = {}
|
|
tbl[hd] = subtbl
|
|
end
|
|
set_field(subtbl, tl, value)
|
|
end
|
|
|
|
|
|
-- Get a field definition from a possibly-nested schema using a string key
|
|
-- such as "x.y.z", such that `get_field(t, "x.y.z")` is the
|
|
-- same as `t.x.y.z`.
|
|
local function get_schema_field(schema, name)
|
|
if schema == nil then
|
|
return nil
|
|
end
|
|
local dot = find(name, ".", 1, true)
|
|
if not dot then
|
|
return schema.fields[name]
|
|
end
|
|
local hd, tl = sub(name, 1, dot - 1), sub(name, dot + 1)
|
|
return get_schema_field(schema.fields[hd], tl)
|
|
end
|
|
|
|
|
|
local function mutually_required(entity, field_names)
|
|
local nonempty = {}
|
|
|
|
for i = 1, #field_names do
|
|
local name = field_names[i]
|
|
if is_nonempty(get_field(entity, name)) then
|
|
insert(nonempty, name)
|
|
end
|
|
end
|
|
|
|
if #nonempty == 0 or #nonempty == #field_names then
|
|
return true
|
|
end
|
|
|
|
return nil, quoted_list(field_names)
|
|
end
|
|
|
|
|
|
local function mutually_exclusive(entity, field_names)
|
|
local nonempty = {}
|
|
|
|
for i = 1, #field_names do
|
|
local name = field_names[i]
|
|
if is_nonempty(get_field(entity, name)) then
|
|
insert(nonempty, name)
|
|
end
|
|
end
|
|
|
|
if #nonempty == 0 or #nonempty == 1 then
|
|
return true
|
|
end
|
|
|
|
return nil, quoted_list(field_names)
|
|
end
|
|
|
|
|
|
--- Entity checkers are cross-field validation rules.
|
|
-- An entity checker is implemented as an entry in this table,
|
|
-- containing a mandatory field `fn`, the checker function,
|
|
-- and an optional field `field_sources`.
|
|
--
|
|
-- An entity checker is used in a schema by adding an entry to
|
|
-- the `entity_checks` array of a schema table. Entries
|
|
-- in `entity_checks` are tables with a single key, named
|
|
-- after the entity checker; its value is the "entity check
|
|
-- argument". For example:
|
|
--
|
|
-- entity_checks = {
|
|
-- { only_one_of = { "field_a", "field_b" } },
|
|
-- },
|
|
--
|
|
-- The `fn` function which implements an entity checker receives
|
|
-- three arguments: a projection of the entity containing only
|
|
-- the relevant fields to this checker, the entity check argument,
|
|
-- and the schema table. This ensures that the entity checker
|
|
-- does _not_ have access by default to the entire entity being
|
|
-- checked. This allows us to enable/disable entity checkers on
|
|
-- partial updates.
|
|
--
|
|
-- To specify which fields are relevant to this checker, one
|
|
-- uses the `field_sources`. It is an array of strings, which
|
|
-- are key names to the entity check argument (see the `conditional`
|
|
-- entity checker for an example of its use).
|
|
-- If `field_sources` is not present, it is assumed that the
|
|
-- entity check argument is an array of field names, and that
|
|
-- all of them need to be present for the entity checker to run.
|
|
-- (this is the case, for example, of `only_one_of` in the example
|
|
-- above: this checker forces both fields to be given, and the
|
|
-- implementation of the checker will demand that only one is
|
|
-- non-empty).
|
|
Schema.entity_checkers = {
|
|
|
|
at_least_one_of = {
|
|
run_with_missing_fields = true,
|
|
run_with_invalid_fields = true,
|
|
fn = function(entity, field_names)
|
|
for i = 1, #field_names do
|
|
local name = field_names[i]
|
|
if is_nonempty(get_field(entity, name)) then
|
|
return true
|
|
end
|
|
end
|
|
|
|
return nil, quoted_list(field_names)
|
|
end,
|
|
},
|
|
|
|
conditional_at_least_one_of = {
|
|
run_with_missing_fields = true,
|
|
run_with_invalid_fields = true,
|
|
field_sources = {
|
|
"if_field",
|
|
"then_at_least_one_of",
|
|
"else_then_at_least_one_of",
|
|
},
|
|
required_fields = { "if_field" },
|
|
fn = function(entity, arg, schema)
|
|
local if_value = get_field(entity, arg.if_field)
|
|
if if_value == nil then
|
|
return true
|
|
end
|
|
|
|
local arg_mt = {
|
|
__index = get_schema_field(schema, arg.if_field),
|
|
}
|
|
|
|
setmetatable(arg.if_match, arg_mt)
|
|
local ok, _ = Schema.validate_field(schema, arg.if_match, if_value)
|
|
if not ok then
|
|
if arg.else_match == nil then
|
|
return true
|
|
end
|
|
|
|
-- run 'else'
|
|
setmetatable(arg.else_match, arg_mt)
|
|
local ok, _ = Schema.validate_field(schema, arg.else_match, if_value)
|
|
if not ok then
|
|
return true
|
|
end
|
|
|
|
local names = arg.else_then_at_least_one_of
|
|
for i = 1, #names do
|
|
if is_nonempty(get_field(entity, names[i])) then
|
|
return true
|
|
end
|
|
end
|
|
|
|
local list = quoted_list(arg.else_then_at_least_one_of)
|
|
local else_then_err
|
|
if arg.else_then_err then
|
|
else_then_err = arg.else_then_err:format(list)
|
|
end
|
|
|
|
return nil, list, else_then_err
|
|
end
|
|
|
|
-- run 'if'
|
|
local names = arg.then_at_least_one_of
|
|
for i = 1, #names do
|
|
if is_nonempty(get_field(entity, names[i])) then
|
|
return true
|
|
end
|
|
end
|
|
|
|
local list = quoted_list(arg.then_at_least_one_of)
|
|
local then_err
|
|
if arg.then_err then
|
|
then_err = arg.then_err:format(list)
|
|
end
|
|
|
|
return nil, list, then_err
|
|
end,
|
|
},
|
|
|
|
only_one_of = {
|
|
run_with_missing_fields = false,
|
|
run_with_invalid_fields = true,
|
|
fn = function(entity, field_names)
|
|
local found = false
|
|
local ok = false
|
|
for i = 1, #field_names do
|
|
if is_nonempty(get_field(entity, field_names[i])) then
|
|
if not found then
|
|
found = true
|
|
ok = true
|
|
else
|
|
ok = false
|
|
end
|
|
end
|
|
end
|
|
|
|
if ok then
|
|
return true
|
|
end
|
|
return nil, quoted_list(field_names)
|
|
end,
|
|
},
|
|
|
|
distinct = {
|
|
run_with_missing_fields = false,
|
|
run_with_invalid_fields = true,
|
|
fn = function(entity, field_names)
|
|
local seen = {}
|
|
for i = 1, #field_names do
|
|
local value = get_field(entity, field_names[i])
|
|
if is_nonempty(value) then
|
|
if seen[value] then
|
|
return nil, quoted_list(field_names)
|
|
end
|
|
seen[value] = true
|
|
end
|
|
end
|
|
return true
|
|
end,
|
|
},
|
|
|
|
--- Conditional validation: if the first field passes the given validator,
|
|
-- then run the validator against the second field.
|
|
-- Example:
|
|
-- ```
|
|
-- conditional = { if_field = "policy",
|
|
-- if_match = { match = "^redis$" },
|
|
-- then_field = "redis_host",
|
|
-- then_match = { required = true } }
|
|
-- ```
|
|
conditional = {
|
|
run_with_missing_fields = false,
|
|
run_with_invalid_fields = true,
|
|
field_sources = { "if_field", "then_field" },
|
|
required_fields = { ["if_field"] = true },
|
|
fn = function(entity, arg, schema, errors)
|
|
local if_value = get_field(entity, arg.if_field)
|
|
local then_value = get_field(entity, arg.then_field)
|
|
if then_value == nil then
|
|
then_value = null
|
|
end
|
|
|
|
setmetatable(arg.if_match, {
|
|
__index = get_schema_field(schema, arg.if_field)
|
|
})
|
|
local ok, _ = Schema.validate_field(schema, arg.if_match, if_value)
|
|
if not ok then
|
|
return true
|
|
end
|
|
|
|
-- Handle `required`
|
|
if arg.then_match.required == true and then_value == null then
|
|
set_field(errors, arg.then_field, validation_errors.REQUIRED)
|
|
return nil, arg.if_field
|
|
end
|
|
|
|
setmetatable(arg.then_match, {
|
|
__index = get_schema_field(schema, arg.then_field)
|
|
})
|
|
local err
|
|
ok, err = Schema.validate_field(schema, arg.then_match, then_value)
|
|
if not ok then
|
|
set_field(errors, arg.then_field, err)
|
|
|
|
local then_err
|
|
if arg.then_err then
|
|
then_err = arg.then_err:format(arg.if_field)
|
|
end
|
|
|
|
return nil, arg.if_field, then_err
|
|
end
|
|
|
|
return true
|
|
end,
|
|
},
|
|
|
|
custom_entity_check = {
|
|
run_with_missing_fields = false,
|
|
run_with_invalid_fields = false,
|
|
field_sources = { "field_sources" },
|
|
required_fields = { ["field_sources"] = true },
|
|
fn = function(entity, arg)
|
|
return arg.fn(entity)
|
|
end,
|
|
},
|
|
|
|
mutually_required = {
|
|
run_with_missing_fields = true,
|
|
fn = mutually_required,
|
|
},
|
|
|
|
mutually_exclusive = {
|
|
run_with_missing_fields = true,
|
|
fn = mutually_exclusive,
|
|
},
|
|
|
|
mutually_exclusive_sets = {
|
|
run_with_missing_fields = true,
|
|
field_sources = { "set1", "set2" },
|
|
required_fields = { "set1", "set2" },
|
|
|
|
fn = function(entity, args)
|
|
local nonempty1 = {}
|
|
local nonempty2 = {}
|
|
|
|
for i = 1, #args.set1 do
|
|
local name = args.set1[i]
|
|
if is_nonempty(get_field(entity, name)) then
|
|
insert(nonempty1, name)
|
|
end
|
|
end
|
|
|
|
for i = 1, #args.set2 do
|
|
local name = args.set2[i]
|
|
if is_nonempty(get_field(entity, name)) then
|
|
insert(nonempty2, name)
|
|
end
|
|
end
|
|
|
|
if #nonempty1 > 0 and #nonempty2 > 0 then
|
|
return nil, format("(%s), (%s)", quoted_list(nonempty1),
|
|
quoted_list(nonempty2))
|
|
end
|
|
|
|
return true
|
|
end
|
|
},
|
|
}
|
|
|
|
|
|
local function memoize(fn)
|
|
local cache = setmetatable({}, { __mode = "k" })
|
|
return function(k)
|
|
if cache[k] then
|
|
return cache[k]
|
|
end
|
|
local v = fn(k)
|
|
cache[k] = v
|
|
return v
|
|
end
|
|
end
|
|
|
|
|
|
local function validate_elements(self, field, value)
|
|
field.elements.required = true
|
|
local errs = {}
|
|
local all_ok = true
|
|
for i = 1, #value do
|
|
local ok, err = self:validate_field(field.elements, value[i])
|
|
if not ok then
|
|
errs[i] = err
|
|
all_ok = false
|
|
end
|
|
end
|
|
|
|
if all_ok then
|
|
return true
|
|
else
|
|
return nil, errs
|
|
end
|
|
end
|
|
|
|
|
|
local get_field_schema = memoize(function(field)
|
|
return Schema.new(field)
|
|
end)
|
|
|
|
|
|
-- Forward declaration
|
|
local validate_fields
|
|
|
|
|
|
--- Validate the field according to the schema.
|
|
-- For aggregate values, validate each field recursively.
|
|
-- @param self The complete schema object.
|
|
-- @param field The schema definition for the field.
|
|
-- @param value The value to be checked (may be ngx.null).
|
|
-- @return true if the field validates correctly;
|
|
-- nil and an error message on failure.
|
|
function Schema:validate_field(field, value)
|
|
yield(true)
|
|
|
|
if value == null then
|
|
if field.ne == null then
|
|
return nil, field.err or validation_errors.NE:format("null")
|
|
end
|
|
if field.eq ~= nil and field.eq ~= null then
|
|
return nil, validation_errors.EQ:format(tostring(field.eq))
|
|
end
|
|
if field.required then
|
|
return nil, validation_errors.REQUIRED
|
|
end
|
|
return true
|
|
end
|
|
|
|
if field.eq == null then
|
|
return nil, field.err or validation_errors.EQ:format("null")
|
|
end
|
|
|
|
if field.abstract then
|
|
return nil, validation_errors.SUBSCHEMA_ABSTRACT_FIELD
|
|
end
|
|
|
|
if field.deprecation then
|
|
local old_default = field.deprecation.old_default
|
|
local should_warn = kong and kong.configuration and kong.configuration.role ~= "data_plane" and
|
|
(old_default == nil
|
|
or not deepcompare(value, old_default))
|
|
if should_warn then
|
|
deprecation(field.deprecation.message,
|
|
{ after = field.deprecation.removal_in_version, })
|
|
end
|
|
end
|
|
|
|
if field.type == "array" then
|
|
if not is_sequence(value) then
|
|
return nil, validation_errors.ARRAY
|
|
end
|
|
|
|
local ok, err = validate_elements(self, field, value)
|
|
if not ok then
|
|
return nil, err
|
|
end
|
|
|
|
elseif field.type == "set" then
|
|
if not is_sequence(value) then
|
|
return nil, validation_errors.SET
|
|
end
|
|
|
|
field.elements.required = true
|
|
local ok, err = validate_elements(self, field, value)
|
|
if not ok then
|
|
return nil, err
|
|
end
|
|
|
|
elseif field.type == "map" then
|
|
if type(value) ~= "table" then
|
|
return nil, validation_errors.MAP
|
|
end
|
|
|
|
field.keys.required = true
|
|
field.values.required = true
|
|
for k, v in pairs(value) do
|
|
local ok, err
|
|
ok, err = self:validate_field(field.keys, k)
|
|
if not ok then
|
|
return nil, err
|
|
end
|
|
ok, err = self:validate_field(field.values, v)
|
|
if not ok then
|
|
return nil, err
|
|
end
|
|
end
|
|
|
|
elseif field.type == "record" then
|
|
if type(value) ~= "table" then
|
|
return nil, validation_errors.RECORD
|
|
end
|
|
|
|
local field_schema = get_field_schema(field)
|
|
-- TODO return nested table or string?
|
|
local copy = field_schema:process_auto_fields(value, "insert")
|
|
-- TODO: explain why we need to make a copy?
|
|
local ok, err = field_schema:validate(copy)
|
|
if not ok then
|
|
return nil, err
|
|
end
|
|
|
|
elseif field.type == "foreign" then
|
|
if field.schema and field.schema.validate_primary_key then
|
|
local ok, errs = field.schema:validate_primary_key(value, true)
|
|
if not ok then
|
|
if type(value) == "table" and field.schema.validate then
|
|
local foreign_ok, foreign_errs = field.schema:validate(value, false)
|
|
if not foreign_ok then
|
|
return nil, foreign_errs
|
|
end
|
|
end
|
|
|
|
return ok, errs
|
|
end
|
|
end
|
|
|
|
elseif field.type == "integer" then
|
|
if not (type(value) == "number"
|
|
and value == floor(value)
|
|
and value ~= 1/0
|
|
and value ~= -1/0) then
|
|
return nil, validation_errors.INTEGER
|
|
end
|
|
|
|
elseif field.type == "string" then
|
|
if type(value) ~= "string" then
|
|
return nil, validation_errors.STRING
|
|
end
|
|
-- empty strings are not accepted by default
|
|
if not field.len_min then
|
|
field.len_min = 1
|
|
end
|
|
|
|
if field.referenceable and is_reference(value) then
|
|
return true
|
|
end
|
|
|
|
elseif field.type == "function" then
|
|
if type(value) ~= "function" then
|
|
return nil, validation_errors.FUNCTION
|
|
end
|
|
|
|
elseif self.valid_types[field.type] then
|
|
if type(value) ~= field.type then
|
|
return nil, validation_errors[field.type:upper()]
|
|
end
|
|
|
|
-- if type is "any" (an internal marker), run validators only
|
|
elseif field.type ~= "any" then
|
|
return nil, validation_errors.SCHEMA_TYPE:format(field.type)
|
|
end
|
|
|
|
local validators = Schema.validators_order
|
|
for i = 1, #validators do
|
|
local k = validators[i]
|
|
if field[k] ~= nil then
|
|
local ok, err = self.validators[k](value, field[k], field)
|
|
if not ok then
|
|
if not err then
|
|
err = (validation_errors[k:upper()]
|
|
or validation_errors.VALIDATION):format(value)
|
|
end
|
|
return nil, field.err or err
|
|
end
|
|
end
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
--- Given missing field named `k`, with definition `field`,
|
|
-- fill its slot in `entity` with an appropriate default value,
|
|
-- if possible.
|
|
-- @param field The field definition table.
|
|
local function handle_missing_field(field, value, opts)
|
|
local no_defaults = opts and opts.no_defaults
|
|
if field.default ~= nil and not no_defaults then
|
|
local copy = cycle_aware_deep_copy(field.default)
|
|
if (field.type == "array" or field.type == "set")
|
|
and type(copy) == "table"
|
|
and not getmetatable(copy)
|
|
then
|
|
setmetatable(copy, cjson.array_mt)
|
|
end
|
|
return copy
|
|
end
|
|
|
|
-- If `nilable` (metaschema only), a default value is not necessary.
|
|
if field.nilable then
|
|
return value
|
|
end
|
|
|
|
-- If not `required`, it is nullable.
|
|
if field.required ~= true then
|
|
return null
|
|
end
|
|
|
|
if field.abstract then
|
|
return nil
|
|
end
|
|
|
|
-- if the missing field is a record, expand its structure
|
|
-- to obtain any nested defaults
|
|
if field.type == "record" then
|
|
local field_schema = get_field_schema(field)
|
|
return field_schema:process_auto_fields({}, "insert")
|
|
end
|
|
|
|
-- If `required`, it will fail later.
|
|
return nil
|
|
end
|
|
|
|
|
|
--- Check if subschema field is compatible with the abstract field it replaces.
|
|
-- @return true if compatible, false otherwise.
|
|
local function compatible_fields(f1, f2)
|
|
local t1, t2 = f1.type, f2.type
|
|
if t1 == "record" and t2 == "json" then
|
|
return true
|
|
end
|
|
if t1 ~= t2 then
|
|
return false
|
|
end
|
|
if t1 == "record" then
|
|
return true
|
|
end
|
|
if t1 == "array" or t1 == "set" then
|
|
return f1.elements.type == f2.elements.type
|
|
end
|
|
if t1 == "map" then
|
|
return f1.keys.type == f2.keys.type and f1.values.type == f2.values.type
|
|
end
|
|
return true
|
|
end
|
|
|
|
|
|
local function get_subschema(self, input)
|
|
if self.subschemas and self.subschema_key then
|
|
local input_key = input[self.subschema_key]
|
|
|
|
if type(input_key) == "string" then
|
|
return self.subschemas[input_key]
|
|
end
|
|
|
|
if type(input_key) == "table" then -- if subschema key is a set, return
|
|
for i = 1, #input_key do -- subschema for first key
|
|
local subschema = self.subschemas[input_key[i]]
|
|
if subschema then
|
|
return subschema
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
|
|
local function resolve_field(self, k, field, subschema)
|
|
field = field or self.fields[tostring(k)]
|
|
if not field then
|
|
return nil, validation_errors.UNKNOWN
|
|
end
|
|
if subschema then
|
|
local ss_field = subschema.fields[k]
|
|
if ss_field then
|
|
field = ss_field
|
|
end
|
|
end
|
|
return field
|
|
end
|
|
|
|
|
|
---@param field table
|
|
---@param field_name string
|
|
---@param input table
|
|
---@return kong.db.schema.json.schema_doc? schema
|
|
---@return string? error
|
|
local function get_json_schema(field, field_name, input)
|
|
local json_schema = field.json_schema
|
|
|
|
local schema = json_schema.inline
|
|
if schema then
|
|
return schema
|
|
end
|
|
|
|
local parent_key = json_schema.parent_subschema_key
|
|
local subschema_key = input[parent_key]
|
|
|
|
if subschema_key then
|
|
local schema_name = json_schema.namespace .. "/" .. subschema_key
|
|
schema = json.get_schema(schema_name) or json_schema.default
|
|
|
|
if schema then
|
|
return schema
|
|
|
|
elseif not json_schema.optional then
|
|
return nil, validation_errors.JSON_SCHEMA_NOT_FOUND:format(schema_name)
|
|
end
|
|
|
|
elseif not json_schema.optional then
|
|
return nil, validation_errors.JSON_PARENT_KEY_MISSING:format(field_name, parent_key)
|
|
end
|
|
|
|
-- no error: schema is optional
|
|
end
|
|
|
|
|
|
---@param field table # Lua schema definition for this field
|
|
---@param field_name string
|
|
---@param input table # full input table that this field appears in
|
|
---@return boolean? ok
|
|
---@return string? error
|
|
local function validate_json_field(field, field_name, input)
|
|
local schema, err = get_json_schema(field, field_name, input)
|
|
if schema then
|
|
return json_validate(input[field_name], schema)
|
|
|
|
elseif err then
|
|
return nil, err
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
--- Validate fields of a table, individually, against the schema.
|
|
-- @param self The schema
|
|
-- @param input The input table.
|
|
-- @return Two arguments: the first is true on success and nil on failure.
|
|
-- The second is a table containing all errors, indexed numerically for
|
|
-- general errors, and by field name for field errors.
|
|
-- In all cases, the input table is untouched.
|
|
validate_fields = function(self, input)
|
|
assert(type(input) == "table", validation_errors.BAD_INPUT)
|
|
|
|
local errors, _ = {}
|
|
|
|
local subschema = get_subschema(self, input)
|
|
local subschema_fields = subschema and subschema.fields or EMPTY
|
|
|
|
for k, v in pairs(input) do
|
|
local err
|
|
local field = self.fields[tostring(k)]
|
|
local subschema_field = subschema_fields[tostring(k)]
|
|
|
|
if field and field.type == "json"
|
|
or (subschema_field and subschema_field.type == "json")
|
|
then
|
|
_, errors[k] = validate_json_field(subschema_field or field, k, input)
|
|
|
|
elseif field and field.type == "self" then
|
|
local pok
|
|
pok, err, errors[k] = pcall(self.validate_field, self, input, v)
|
|
if not pok then
|
|
errors[k] = validation_errors.SCHEMA_CANNOT_VALIDATE
|
|
kong.log.debug(errors[k], ": ", err)
|
|
end
|
|
|
|
elseif not self.unvalidated_fields[k]() then
|
|
field, err = resolve_field(self, k, field, subschema)
|
|
if field then
|
|
_, errors[k] = self:validate_field(field, v)
|
|
elseif err == validation_errors.UNKNOWN and v == null and
|
|
kong and kong.configuration and
|
|
kong.configuration.role == "data_plane" then -- luacheck: ignore
|
|
-- extra fields with value of null in the input config are ignored
|
|
-- otherwise record the error
|
|
else
|
|
errors[k] = err
|
|
end
|
|
end
|
|
end
|
|
|
|
if next(errors) then
|
|
return nil, errors
|
|
end
|
|
return true, errors
|
|
end
|
|
|
|
|
|
local function insert_entity_error(errors, err)
|
|
if not errors["@entity"] then
|
|
errors["@entity"] = {}
|
|
end
|
|
insert(errors["@entity"], err)
|
|
end
|
|
|
|
|
|
--- Runs an entity check, making sure it has access to all fields it asked for,
|
|
-- and that it only has access to the fields it asked for.
|
|
-- It will call `self.entity_checkers[name]` giving it a subset of `input`,
|
|
-- based on the list of fields given at `schema.entity_checks[name].fields`.
|
|
-- @param self The schema table
|
|
-- @param name The name of the entity check
|
|
-- @param input The whole input entity.
|
|
-- @param arg The argument table of the entity check declaration
|
|
-- @param errors The table where errors are accumulated.
|
|
-- @return Nothing; the side-effect of this function is to add entries to
|
|
-- `errors` if any errors occur.
|
|
local function run_entity_check(self, name, input, arg, full_check, errors)
|
|
local check_input = {}
|
|
local checker = self.entity_checkers[name]
|
|
local fields_to_check = {}
|
|
|
|
local required_fields = {}
|
|
if checker.field_sources then
|
|
for i = 1, #checker.field_sources do
|
|
local source = checker.field_sources[i]
|
|
local v = arg[source]
|
|
if type(v) == "string" then
|
|
insert(fields_to_check, v)
|
|
if checker.required_fields[source] then
|
|
required_fields[v] = true
|
|
end
|
|
elseif type(v) == "table" then
|
|
for j = 1, #v do
|
|
local fname = v[j]
|
|
insert(fields_to_check, fname)
|
|
if checker.required_fields[source] then
|
|
required_fields[fname] = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
else
|
|
fields_to_check = arg
|
|
for i = 1, #arg do
|
|
required_fields[arg[i]] = true
|
|
end
|
|
end
|
|
|
|
local missing
|
|
local all_nil = true
|
|
local all_ok = true
|
|
for i = 1, #fields_to_check do
|
|
local fname = fields_to_check[i]
|
|
local value = get_field(input, fname)
|
|
if value == nil then
|
|
if (not checker.run_with_missing_fields) and
|
|
(not arg.run_with_missing_fields) and
|
|
(required_fields and required_fields[fname]) and
|
|
(not (get_schema_field(self, fname) or {}).nilable) then
|
|
missing = missing or {}
|
|
insert(missing, fname)
|
|
end
|
|
else
|
|
all_nil = false
|
|
|
|
-- Don't run if any of the values is a reference in a referenceable field
|
|
local field = get_schema_field(self, fname)
|
|
if field and field.type == "string" and field.referenceable and is_reference(value) then
|
|
return
|
|
end
|
|
end
|
|
if errors[fname] then
|
|
all_ok = false
|
|
end
|
|
set_field(check_input, fname, value)
|
|
end
|
|
|
|
-- Don't run check if any of its fields has errors
|
|
if not all_ok
|
|
and not checker.run_with_invalid_fields
|
|
and not arg.run_with_invalid_fields
|
|
then
|
|
return
|
|
end
|
|
|
|
-- Don't run check if none of the fields are present (update)
|
|
if all_nil and not (checker.run_with_missing_fields and full_check) then
|
|
return
|
|
end
|
|
|
|
-- Don't run check if a required field is missing
|
|
if missing then
|
|
for i = 1, #missing do
|
|
set_field(errors, missing[i], validation_errors.REQUIRED_FOR_ENTITY_CHECK)
|
|
end
|
|
return
|
|
end
|
|
|
|
local ok, err, err2 = checker.fn(check_input, arg, self, errors)
|
|
if ok then
|
|
return
|
|
end
|
|
|
|
if err2 then
|
|
-- user provided custom error for this entity checker
|
|
insert_entity_error(errors, err2)
|
|
|
|
else
|
|
local error_fmt = validation_errors[name:upper()]
|
|
err = error_fmt and error_fmt:format(err) or err
|
|
if not err then
|
|
local data = pretty.write({ name = arg }):gsub("%s+", " ")
|
|
err = validation_errors.ENTITY_CHECK:format(name, data)
|
|
end
|
|
|
|
insert_entity_error(errors, err)
|
|
end
|
|
end
|
|
|
|
|
|
--- Runs the schema's custom `self.check()` function.
|
|
-- It requires the full entity to be present.
|
|
-- TODO hopefully deprecate this function.
|
|
-- @param self The schema table
|
|
-- @param name The name of the entity check
|
|
-- @param errors The current table of accumulated field errors.
|
|
local function run_self_check(self, input, errors)
|
|
local ok = true
|
|
for fname, field in self:each_field() do
|
|
if input[fname] == nil and not field.nilable then
|
|
local err = validation_errors.REQUIRED_FOR_ENTITY_CHECK:format(fname)
|
|
errors[fname] = err
|
|
ok = false
|
|
end
|
|
end
|
|
|
|
if not ok then
|
|
return
|
|
end
|
|
|
|
local err
|
|
ok, err = self.check(input)
|
|
if ok then
|
|
return
|
|
end
|
|
|
|
if type(err) == "string" then
|
|
insert_entity_error(errors, err)
|
|
|
|
elseif type(err) == "table" then
|
|
for k, v in pairs(err) do
|
|
if type(k) == "number" then
|
|
insert_entity_error(errors, v)
|
|
else
|
|
errors[k] = v
|
|
end
|
|
end
|
|
|
|
else
|
|
insert_entity_error(errors, validation_errors.CHECK)
|
|
end
|
|
end
|
|
|
|
|
|
local run_entity_checks
|
|
do
|
|
local function run_checks(self, input, full_check, checks, errors)
|
|
if not checks then
|
|
return
|
|
end
|
|
for i = 1, #checks do
|
|
local check = checks[i]
|
|
local check_name = next(check)
|
|
local arg = check[check_name]
|
|
if arg and arg ~= null then
|
|
run_entity_check(self, check_name, input, arg, full_check, errors)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Run entity checks over the whole table.
|
|
-- This includes the custom `check` function.
|
|
-- In case of any errors, add them to the errors table.
|
|
-- @param self The schema
|
|
-- @param input The input table.
|
|
-- @param full_check If true, demands entity table to be complete.
|
|
-- @param errors The table where errors are accumulated.
|
|
-- @return Nothing; the side-effect of this function is to add entries to
|
|
-- `errors` if any errors occur.
|
|
run_entity_checks = function(self, input, full_check, errors)
|
|
|
|
run_checks(self, input, full_check, self.entity_checks, errors)
|
|
|
|
local subschema = get_subschema(self, input)
|
|
if subschema then
|
|
local fields_proxy = setmetatable({}, {
|
|
__index = function(_, k)
|
|
return subschema.fields[k] or self.fields[k]
|
|
end
|
|
})
|
|
local self_proxy = setmetatable({}, {
|
|
__index = function(_, k)
|
|
if k == "fields" then
|
|
return fields_proxy
|
|
else
|
|
return self[k]
|
|
end
|
|
end
|
|
})
|
|
run_checks(self_proxy, input, full_check, subschema.entity_checks, errors)
|
|
end
|
|
|
|
if self.check and full_check then
|
|
run_self_check(self, input, errors)
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
local function run_transformation_checks(schema_or_subschema, input, original_input, rbw_entity, errors)
|
|
local transformations = schema_or_subschema.transformations
|
|
if transformations then
|
|
for i = 1, #transformations do
|
|
local transformation = transformations[i]
|
|
if transformation.input or transformation.needs then
|
|
local args = {}
|
|
local argc = 0
|
|
local none_set = true
|
|
if transformation.input then
|
|
for j = 1, #transformation.input do
|
|
local input_field_name = transformation.input[j]
|
|
if is_nonempty(get_field(original_input or input, input_field_name)) then
|
|
none_set = false
|
|
end
|
|
|
|
argc = argc + 1
|
|
args[argc] = input_field_name
|
|
end
|
|
end
|
|
|
|
local needs_changed = false
|
|
if transformation.needs then
|
|
for j = 1, #transformation.needs do
|
|
local input_field_name = transformation.needs[j]
|
|
if rbw_entity and not needs_changed then
|
|
local value = get_field(original_input or input, input_field_name)
|
|
local rbw_value = get_field(rbw_entity, input_field_name)
|
|
if value ~= rbw_value then
|
|
needs_changed = true
|
|
end
|
|
end
|
|
|
|
argc = argc + 1
|
|
args[argc] = input_field_name
|
|
end
|
|
end
|
|
|
|
if needs_changed or (not none_set) then
|
|
local ok, err = mutually_required(needs_changed and original_input or input, args)
|
|
if not ok then
|
|
insert_entity_error(errors, validation_errors.MUTUALLY_REQUIRED:format(err))
|
|
|
|
else
|
|
ok, err = mutually_required(original_input or input, transformation.input)
|
|
if not ok then
|
|
insert_entity_error(errors, validation_errors.MUTUALLY_REQUIRED:format(err))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local subschema = get_subschema(schema_or_subschema, input)
|
|
if subschema then
|
|
run_transformation_checks(subschema, input, original_input, rbw_entity, errors)
|
|
end
|
|
end
|
|
|
|
|
|
--- Ensure that a given table contains only the primary-key
|
|
-- fields of the entity and that their fields validate.
|
|
-- @param pk A table with primary-key fields only.
|
|
-- @param ignore_others If true, the function will ignore non-primary key
|
|
-- entries.
|
|
-- @return True on success; nil, error message and error table otherwise.
|
|
function Schema:validate_primary_key(pk, ignore_others)
|
|
local pk_set = {}
|
|
local errors = {}
|
|
|
|
local primary_key = self.primary_key
|
|
for i = 1, #primary_key do
|
|
local k = primary_key[i]
|
|
pk_set[k] = true
|
|
local field = self.fields[k]
|
|
local v = pk[k]
|
|
|
|
if not v then
|
|
errors[k] = validation_errors.MISSING_PK
|
|
|
|
elseif (field.required or field.auto) and v == null then
|
|
errors[k] = validation_errors.REQUIRED
|
|
|
|
else
|
|
local _
|
|
_, errors[k] = self:validate_field(field, v)
|
|
end
|
|
end
|
|
|
|
if not ignore_others then
|
|
for k, _ in pairs(pk) do
|
|
if not pk_set[k] then
|
|
errors[k] = validation_errors.NOT_PK
|
|
end
|
|
end
|
|
end
|
|
|
|
if next(errors) then
|
|
return nil, errors
|
|
end
|
|
return true
|
|
end
|
|
|
|
|
|
local as_set = setmetatable({}, { __mode = "k" })
|
|
|
|
|
|
local Set_mt = {
|
|
__index = function(t, key)
|
|
local set = as_set[t]
|
|
if set then
|
|
return set[key]
|
|
end
|
|
end
|
|
}
|
|
|
|
|
|
--- Sets (or replaces) metatable of an array:
|
|
-- 1. array is a proper sequence, `cjson.array_mt`
|
|
-- will be used as a metatable of the returned array.
|
|
-- 2. otherwise no modifications are made to input parameter.
|
|
-- @param array The table containing an array for which to apply the metatable.
|
|
-- @return input table (with metatable, see above)
|
|
local function make_array(array)
|
|
if is_sequence(array) then
|
|
return setmetatable(array, cjson.array_mt)
|
|
end
|
|
|
|
return array
|
|
end
|
|
|
|
|
|
--- Sets (or replaces) metatable of a set and removes duplicates:
|
|
-- 1. set is a proper sequence, but empty, `cjson.array_mt`
|
|
-- will be used as a metatable of the returned set.
|
|
-- 2. set a proper sequence, and has values, `Set_mt`
|
|
-- will be used as a metatable of the returned set.
|
|
-- 3. otherwise no modifications are made to input parameter.
|
|
-- @param set The table containing a set for which to apply the metatable.
|
|
-- @return input table (with metatable, see above)
|
|
local function make_set(set)
|
|
if not is_sequence(set) then
|
|
return set
|
|
end
|
|
|
|
local count = #set
|
|
|
|
if count == 0 then
|
|
return setmetatable(set, cjson.array_mt)
|
|
end
|
|
|
|
local o = {}
|
|
local s = {}
|
|
local j = 0
|
|
|
|
for i = 1, count do
|
|
local v = set[i]
|
|
if not s[v] then
|
|
j = j + 1
|
|
o[j] = v
|
|
s[v] = true
|
|
end
|
|
end
|
|
|
|
as_set[o] = s
|
|
|
|
return setmetatable(o, Set_mt)
|
|
end
|
|
|
|
|
|
local function should_recurse_record(context, value, field)
|
|
if context == "update" then
|
|
return value ~= null and value ~= nil
|
|
else
|
|
return value ~= null and (value ~= nil or field.required == true)
|
|
end
|
|
end
|
|
|
|
|
|
local function adjust_field_for_context(field, value, context, nulls, opts)
|
|
if context == "select" and value == null and field.required == true then
|
|
return handle_missing_field(field, value, opts)
|
|
end
|
|
|
|
if field.abstract then
|
|
return value
|
|
end
|
|
|
|
if field.type == "record" then
|
|
if should_recurse_record(context, value, field) then
|
|
value = value or handle_missing_field(field, value, opts)
|
|
if type(value) == "table" then
|
|
local field_schema = get_field_schema(field)
|
|
return field_schema:process_auto_fields(value, context, nulls, opts)
|
|
end
|
|
end
|
|
|
|
elseif type(value) == "table" then
|
|
local subfield
|
|
if field.type == "array" then
|
|
value = make_array(value)
|
|
subfield = field.elements
|
|
|
|
elseif field.type == "set" then
|
|
value = make_set(value)
|
|
subfield = field.elements
|
|
|
|
elseif field.type == "map" then
|
|
subfield = field.values
|
|
end
|
|
|
|
if subfield then
|
|
-- uses pairs also for arrays and sets as well as maps, as there can be holes
|
|
for k, v in pairs(value) do
|
|
value[k] = adjust_field_for_context(subfield, v, context, nulls, opts)
|
|
end
|
|
end
|
|
end
|
|
|
|
if value == nil and context ~= "update" then
|
|
return handle_missing_field(field, value, opts)
|
|
end
|
|
|
|
return value
|
|
end
|
|
|
|
|
|
local function resolve_reference(kong, value)
|
|
local deref, err = kong.vault.get(value)
|
|
if not deref then
|
|
if err then
|
|
kong.log.warn("unable to resolve reference ", value, " (", err, ")")
|
|
else
|
|
kong.log.notice("unable to resolve reference ", value)
|
|
end
|
|
end
|
|
return deref or ""
|
|
end
|
|
|
|
|
|
local function collect_previous_references(prev_refs, key, refs)
|
|
if prev_refs and prev_refs[key] then
|
|
if refs then
|
|
if not refs[key] then
|
|
refs[key] = prev_refs[key]
|
|
end
|
|
|
|
else
|
|
refs = { [key] = prev_refs[key] }
|
|
end
|
|
end
|
|
return refs
|
|
end
|
|
|
|
|
|
local function collect_subfield_reference(refs, key, references, index, narr, nrec)
|
|
if not refs then
|
|
refs = {
|
|
[key] = new_tab(narr, nrec)
|
|
}
|
|
elseif not refs[key] then
|
|
refs[key] = new_tab(narr, nrec)
|
|
end
|
|
refs[key][index] = references[index]
|
|
return refs
|
|
end
|
|
|
|
|
|
local function collect_field_reference(refs, key, reference)
|
|
if refs then
|
|
refs[key] = reference
|
|
else
|
|
refs = { [key] = reference }
|
|
end
|
|
|
|
return refs
|
|
end
|
|
|
|
|
|
local function validate_deprecation_exclusiveness(data, shorthand_value, shorthand_name, shorthand_definition)
|
|
if shorthand_value == nil or
|
|
shorthand_value == ngx.null or
|
|
shorthand_definition.deprecation == nil or
|
|
(shorthand_definition.deprecation.replaced_with == nil and shorthand_definition.translate_backwards == nil) then
|
|
return true
|
|
end
|
|
|
|
if shorthand_definition.deprecation.replaced_with then
|
|
for _, replaced_with_element in ipairs(shorthand_definition.deprecation.replaced_with) do
|
|
local new_field_value = replaced_with_element.reverse_mapping_function and replaced_with_element.reverse_mapping_function(data)
|
|
or table_path(data, replaced_with_element.path)
|
|
|
|
if new_field_value and
|
|
new_field_value ~= ngx.null and
|
|
not deepcompare(new_field_value, shorthand_value) then
|
|
local new_field_name = join_string(".", replaced_with_element.path)
|
|
|
|
return nil, string.format(
|
|
"both deprecated and new field are used but their values mismatch: %s = %s vs %s = %s",
|
|
shorthand_name, tostring(shorthand_value),
|
|
new_field_name, tostring(new_field_value)
|
|
)
|
|
end
|
|
end
|
|
elseif shorthand_definition.translate_backwards then
|
|
local new_field_value = shorthand_definition.translate_backwards_with and shorthand_definition.translate_backwards_with(data)
|
|
or table_path(data, shorthand_definition.translate_backwards)
|
|
|
|
if new_field_value and
|
|
new_field_value ~= ngx.null and
|
|
not deepcompare(new_field_value, shorthand_value) then
|
|
local new_field_name = join_string(".", shorthand_definition.translate_backwards)
|
|
|
|
return nil, string.format(
|
|
"both deprecated and new field are used but their values mismatch: %s = %s vs %s = %s",
|
|
shorthand_name, tostring(shorthand_value),
|
|
new_field_name, tostring(new_field_value)
|
|
)
|
|
end
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
--- Given a table, update its fields whose schema
|
|
-- definition declares them as `auto = true`,
|
|
-- based on its CRUD operation context, and set
|
|
-- defaults for missing values when the CRUD context
|
|
-- is "insert".
|
|
-- This function encapsulates various "smart behaviors"
|
|
-- for value creation and update.
|
|
-- @param data The table containing data to be processed.
|
|
-- @param context a string describing the CRUD context:
|
|
-- valid values are: "insert", "update", "upsert", "select"
|
|
-- @param nulls boolean: return nulls as explicit ngx.null values
|
|
-- @return A new table, with the auto fields containing
|
|
-- appropriate updated values (except for "select" context
|
|
-- it does it in place by modifying the data directly).
|
|
function Schema:process_auto_fields(data, context, nulls, opts)
|
|
yield(true)
|
|
|
|
local check_immutable_fields = false
|
|
|
|
local is_select = context == "select"
|
|
if not is_select then
|
|
data = cycle_aware_deep_copy(data)
|
|
end
|
|
|
|
local shorthand_fields = self.shorthand_fields
|
|
if shorthand_fields then
|
|
local errs = {}
|
|
local has_errs
|
|
for i = 1, #shorthand_fields do
|
|
local sname, sdata = next(shorthand_fields[i])
|
|
local value = data[sname]
|
|
if value ~= nil then
|
|
local _, err = self:validate_field(sdata, value)
|
|
if err then
|
|
errs[sname] = err
|
|
has_errs = true
|
|
else
|
|
local _, deprecation_error = validate_deprecation_exclusiveness(data, value, sname, sdata)
|
|
|
|
if deprecation_error then
|
|
errs[sname] = deprecation_error
|
|
has_errs = true
|
|
else
|
|
data[sname] = nil
|
|
local new_values = sdata.func(value)
|
|
if new_values then
|
|
-- a shorthand field may have a deprecation property, that is used
|
|
-- to determine whether the shorthand's return value takes precedence
|
|
-- over the similarly named actual schema fields' value when both
|
|
-- are present. On deprecated shorthand fields the actual schema
|
|
-- field value takes the precedence, otherwise the shorthand's
|
|
-- return value takes the precedence.
|
|
local deprecation = sdata.deprecation
|
|
for k, v in pairs(new_values) do
|
|
if type(v) == "table" then
|
|
local source = {}
|
|
if data[k] and data[k] ~= null then
|
|
source = data[k]
|
|
end
|
|
data[k] = deprecation and null_aware_table_merge(v, source)
|
|
or table_merge(source, v)
|
|
|
|
elseif not deprecation or (data[k] == nil or data[k] == null) then
|
|
data[k] = v
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if is_select and not(opts and opts.hide_shorthands) then
|
|
local replaced_with = sdata.deprecation and sdata.deprecation.replaced_with and
|
|
sdata.deprecation.replaced_with[1]
|
|
if replaced_with then
|
|
if replaced_with.reverse_mapping_function then
|
|
data[sname] = replaced_with.reverse_mapping_function(data)
|
|
else
|
|
data[sname] = table_path(data, replaced_with.path)
|
|
end
|
|
|
|
-- Falling back to processing `translate_backwards` for backwards compatibility
|
|
-- this might be the case when someone is using `rate-limiting`, `acme`, `response-ratelimiting` plugin
|
|
-- from version 3.6.x or 3.7.x
|
|
elseif sdata.translate_backwards then
|
|
data[sname] = table_path(data, sdata.translate_backwards)
|
|
end
|
|
end
|
|
end
|
|
if has_errs then
|
|
return nil, errs
|
|
end
|
|
end
|
|
|
|
local now_s
|
|
local now_ms
|
|
|
|
-- We don't want to resolve references on control planes
|
|
-- and admin api requests, admin api request could be
|
|
-- detected with ngx.ctx.KONG_PHASE, but to limit context
|
|
-- access we use nulls that admin api sets to true.
|
|
local kong = kong
|
|
local resolve_references
|
|
if is_select and not nulls then
|
|
if kong and kong.configuration then
|
|
resolve_references = kong.configuration.role ~= "control_plane"
|
|
else
|
|
resolve_references = true
|
|
end
|
|
end
|
|
|
|
local refs
|
|
local prev_refs = resolve_references and data["$refs"]
|
|
|
|
for key, field in self:each_field(data) do
|
|
local ftype = field.type
|
|
local value = data[key]
|
|
if not is_select and field.auto then
|
|
local is_insert_or_upsert = context == "insert" or context == "upsert"
|
|
if field.uuid then
|
|
if is_insert_or_upsert and value == nil then
|
|
value = uuid()
|
|
end
|
|
|
|
elseif ftype == "string" then
|
|
if is_insert_or_upsert and value == nil then
|
|
value = random_string()
|
|
end
|
|
|
|
elseif (key == "created_at" and is_insert_or_upsert and (value == null or
|
|
value == nil))
|
|
or
|
|
(key == "updated_at" and (is_insert_or_upsert or context == "update"))
|
|
then
|
|
if ftype == "number" then
|
|
if not now_ms then
|
|
update_time()
|
|
now_ms = ngx_now()
|
|
end
|
|
value = now_ms
|
|
|
|
elseif ftype == "integer" then
|
|
if not now_s then
|
|
update_time()
|
|
now_s = ngx_time()
|
|
end
|
|
value = now_s
|
|
end
|
|
end
|
|
end
|
|
|
|
local err
|
|
value, err = adjust_field_for_context(field, value, context, nulls, opts)
|
|
if err then
|
|
return nil, err
|
|
end
|
|
|
|
if is_select then
|
|
local vtype = type(value)
|
|
if value == null and not nulls then
|
|
value = nil
|
|
elseif ftype == "integer" and vtype == "number" then
|
|
value = floor(value)
|
|
end
|
|
|
|
if resolve_references then
|
|
if ftype == "string" and field.referenceable then
|
|
if is_reference(value) then
|
|
refs = collect_field_reference(refs, key, value)
|
|
value = resolve_reference(kong, value)
|
|
else
|
|
refs = collect_previous_references(prev_refs, key, refs)
|
|
end
|
|
|
|
elseif vtype == "table" and (ftype == "array" or ftype == "set") then
|
|
local subfield = field.elements
|
|
if subfield.type == "string" and subfield.referenceable then
|
|
local count = #value
|
|
if count > 0 then
|
|
for i = 1, count do
|
|
if is_reference(value[i]) then
|
|
refs = collect_subfield_reference(refs, key, value, i, count, 0)
|
|
value[i] = resolve_reference(kong, value[i])
|
|
end
|
|
end
|
|
end
|
|
|
|
refs = collect_previous_references(prev_refs, key, refs)
|
|
end
|
|
|
|
elseif vtype == "table" and ftype == "map" then
|
|
local subfield = field.values
|
|
if subfield.type == "string" and subfield.referenceable then
|
|
local count = nkeys(value)
|
|
if count > 0 then
|
|
for k, v in pairs(value) do
|
|
if is_reference(v) then
|
|
refs = collect_subfield_reference(refs, key, value, k, 0, count)
|
|
value[k] = resolve_reference(kong, v)
|
|
end
|
|
end
|
|
end
|
|
|
|
refs = collect_previous_references(prev_refs, key, refs)
|
|
end
|
|
end
|
|
end
|
|
|
|
elseif context == "update" and field.immutable then
|
|
check_immutable_fields = true
|
|
end
|
|
|
|
data[key] = value
|
|
end
|
|
|
|
if not is_select then
|
|
return data, nil, check_immutable_fields
|
|
end
|
|
|
|
if self.ttl and data.ttl == null and not nulls then
|
|
data.ttl = nil
|
|
end
|
|
|
|
local show_ws = opts and opts.show_ws_id
|
|
for key in pairs(data) do
|
|
local field = self.fields[key]
|
|
if field then
|
|
if field.type == "string" and (field.len_min or 1) > 0 and data[key] == "" and not (refs and refs[key])
|
|
then
|
|
data[key] = nulls and null or nil
|
|
end
|
|
|
|
elseif not ((key == "ttl" and self.ttl) or
|
|
(key == "ws_id" and show_ws)) then
|
|
|
|
local should_be_in_output = false
|
|
|
|
if self.shorthand_fields then
|
|
for _, shorthand_field in ipairs(self.shorthand_fields) do
|
|
if shorthand_field[key] then
|
|
local replaced_with = shorthand_field[key].deprecation and shorthand_field[key].deprecation.replaced_with and
|
|
#shorthand_field[key].deprecation.replaced_with[1]
|
|
|
|
-- Either using replaced_with or falling back to processing `translate_backwards` for backwards compatibility
|
|
-- this might be the case when someone is using `rate-limiting`, `acme`, `response-ratelimiting` plugin
|
|
-- from version 3.6.x or 3.7.x
|
|
if replaced_with or shorthand_field[key].translate_backwards then
|
|
should_be_in_output = is_select
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if not should_be_in_output then
|
|
data[key] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
data["$refs"] = refs
|
|
|
|
return data
|
|
end
|
|
|
|
|
|
--- Schema-aware deep-merge of two entities.
|
|
-- Uses schema knowledge to merge two records field-by-field,
|
|
-- but not merge the content of two arrays.
|
|
-- @param top the entity whose values take precedence
|
|
-- @param bottom the entity whose values are the fallback
|
|
-- @return the merged entity
|
|
function Schema:merge_values(top, bottom)
|
|
local output = {}
|
|
bottom = (bottom ~= nil and bottom ~= null) and bottom or {}
|
|
for k,v in pairs(bottom) do
|
|
output[k] = v
|
|
end
|
|
for k,v in pairs(top) do
|
|
output[k] = v
|
|
end
|
|
for key, field in self:each_field(bottom) do
|
|
local top_v = top[key]
|
|
|
|
if top_v == nil then
|
|
output[key] = bottom[key]
|
|
|
|
else
|
|
if field.type == "record" and not field.abstract and type(top_v) == "table" then
|
|
output[key] = get_field_schema(field):merge_values(top_v, bottom[key])
|
|
else
|
|
output[key] = top_v
|
|
end
|
|
end
|
|
end
|
|
return output
|
|
end
|
|
|
|
|
|
--[[
|
|
function Schema:load_translations(translation)
|
|
if not self.translations then
|
|
self.translations = {}
|
|
end
|
|
|
|
for i = 1, #self.translations do
|
|
if self.translations[i] == translation then
|
|
return
|
|
end
|
|
end
|
|
|
|
insert(self.translations, translation)
|
|
end
|
|
--]]
|
|
|
|
|
|
--- Validate a table against the schema, ensuring that the entity is complete.
|
|
-- It validates fields for their attributes,
|
|
-- and runs the global entity checks against the entire table.
|
|
-- @param input The input table.
|
|
-- @param full_check If true, demands entity table to be complete.
|
|
-- If false, accepts missing `required` fields when those are not
|
|
-- needed for global checks.
|
|
-- @param original_input The original input for transformation validations.
|
|
-- @param rbw_entity The read-before-write entity, if any.
|
|
-- @return True on success.
|
|
-- On failure, it returns nil and a table containing all errors,
|
|
-- indexed by field name for field errors, plus an "@entity" key
|
|
-- containing entity-checker and self-check errors.
|
|
-- This is an example of what an error table looks like:
|
|
-- {
|
|
-- ["name"] = "...error message...",
|
|
-- ["service"] = {
|
|
-- ["id"] = "...error message...",
|
|
-- }
|
|
-- ["@entity"] = {
|
|
-- "error message from at_least_one_of",
|
|
-- "error message from other entity validators",
|
|
-- "first error message from self-check function",
|
|
-- "second error message from self-check function",
|
|
-- }
|
|
-- }
|
|
-- In all cases, the input table is untouched.
|
|
function Schema:validate(input, full_check, original_input, rbw_entity)
|
|
if full_check == nil then
|
|
full_check = true
|
|
end
|
|
|
|
if self.subschema_key then
|
|
-- If we can't determine the subschema, do not validate any further
|
|
local key = input[self.subschema_key]
|
|
if key == null or key == nil then
|
|
return nil, {
|
|
[self.subschema_key] = validation_errors.REQUIRED
|
|
}
|
|
end
|
|
|
|
if not get_subschema(self, input) then
|
|
local errmsg = self.subschema_error or validation_errors.SUBSCHEMA_UNKNOWN
|
|
return nil, {
|
|
[self.subschema_key] = errmsg:format(type(key) == "string" and key or key[1])
|
|
}
|
|
end
|
|
end
|
|
|
|
local _, errors = validate_fields(self, input)
|
|
|
|
for name, field in self:each_field() do
|
|
if field.required
|
|
and (input[name] == null
|
|
or (full_check and input[name] == nil)) then
|
|
errors[name] = validation_errors.REQUIRED
|
|
end
|
|
end
|
|
|
|
run_entity_checks(self, input, full_check, errors)
|
|
run_transformation_checks(self, input, original_input, rbw_entity, errors)
|
|
|
|
if next(errors) then
|
|
return nil, errors
|
|
end
|
|
return true
|
|
end
|
|
|
|
|
|
-- Iterate through input fields on update and check against schema for
|
|
-- immutable attribute. If immutable attribute is set, compare input values
|
|
-- against entity values to determine whether input is valid.
|
|
-- @param input The input table.
|
|
-- @param entity The entity update will be performed on.
|
|
-- @return True on success.
|
|
-- On failure, it returns nil and a table containing all errors by field name.
|
|
-- In all cases, the input table is untouched.
|
|
function Schema:validate_immutable_fields(input, entity)
|
|
local errors = {}
|
|
|
|
for key, field in self:each_field(input) do
|
|
local compare = is_array(input[key]) and compare_no_order or deepcompare
|
|
|
|
if field.immutable and entity[key] ~= nil and not compare(input[key], entity[key]) then
|
|
errors[key] = validation_errors.IMMUTABLE
|
|
end
|
|
end
|
|
|
|
if next(errors) then
|
|
return nil, errors
|
|
end
|
|
|
|
return true, errors
|
|
end
|
|
|
|
|
|
--- Validate a table against the schema, ensuring that the entity is complete.
|
|
-- It validates fields for their attributes,
|
|
-- and runs the global entity checks against the entire table.
|
|
-- @param input The input table.
|
|
-- @param original_input The original input for transformation validations.
|
|
-- @return True on success.
|
|
-- On failure, it returns nil and a table containing all errors,
|
|
-- indexed numerically for general errors, and by field name for field errors.
|
|
-- In all cases, the input table is untouched.
|
|
function Schema:validate_insert(input, original_input)
|
|
return self:validate(input, true, original_input)
|
|
end
|
|
|
|
|
|
-- Validate a table against the schema, accepting a partial entity.
|
|
-- It validates fields for their attributes, but accepts missing `required`
|
|
-- fields when those are not needed for global checks,
|
|
-- and runs the global checks against the entire table.
|
|
-- @param input The input table.
|
|
-- @param original_input The original input for transformation validations.
|
|
-- @param rbw_entity The read-before-write entity, if any.
|
|
-- @return True on success.
|
|
-- On failure, it returns nil and a table containing all errors,
|
|
-- indexed numerically for general errors, and by field name for field errors.
|
|
-- In all cases, the input table is untouched.
|
|
function Schema:validate_update(input, original_input, rbw_entity)
|
|
|
|
-- Monkey-patch some error messages to make it clearer why they
|
|
-- apply during an update. This avoids propagating update-awareness
|
|
-- all the way down to the entity checkers (which would otherwise
|
|
-- defeat the whole purpose of the mechanism).
|
|
local rfec = validation_errors.REQUIRED_FOR_ENTITY_CHECK
|
|
local aloo = validation_errors.AT_LEAST_ONE_OF
|
|
local caloo = validation_errors.CONDITIONAL_AT_LEAST_ONE_OF
|
|
validation_errors.REQUIRED_FOR_ENTITY_CHECK = rfec .. " when updating"
|
|
validation_errors.AT_LEAST_ONE_OF = "when updating, " .. aloo
|
|
validation_errors.CONDITIONAL_AT_LEAST_ONE_OF = "when updating, " .. caloo
|
|
|
|
local ok, errors = self:validate(input, false, original_input, rbw_entity)
|
|
|
|
-- Restore the original error messages
|
|
validation_errors.REQUIRED_FOR_ENTITY_CHECK = rfec
|
|
validation_errors.AT_LEAST_ONE_OF = aloo
|
|
validation_errors.CONDITIONAL_AT_LEAST_ONE_OF = caloo
|
|
|
|
return ok, errors
|
|
end
|
|
|
|
|
|
--- Validate a table against the schema, ensuring that the entity is complete.
|
|
-- It validates fields for their attributes,
|
|
-- and runs the global entity checks against the entire table.
|
|
-- @param input The input table.
|
|
-- @param original_input The original input for transformation validations.
|
|
-- @return True on success.
|
|
-- On failure, it returns nil and a table containing all errors,
|
|
-- indexed numerically for general errors, and by field name for field errors.
|
|
-- In all cases, the input table is untouched.
|
|
function Schema:validate_upsert(input, original_input)
|
|
return self:validate(input, true, original_input)
|
|
end
|
|
|
|
|
|
--- An iterator for schema fields.
|
|
-- Returns a function to be used in `for` loops,
|
|
-- which produces the key and the field table,
|
|
-- as in `for field_name, field_data in self:each_field() do`
|
|
-- @param values An instance of the entity, which is used
|
|
-- only to determine which subschema to use.
|
|
-- @return the iteration function
|
|
function Schema:each_field(values)
|
|
local i = 1
|
|
|
|
local subschema
|
|
if values then
|
|
subschema = get_subschema(self, values)
|
|
end
|
|
|
|
return function()
|
|
local item = self.fields[i]
|
|
if not item then
|
|
return nil
|
|
end
|
|
local key = next(item)
|
|
local field = resolve_field(self, key, item[key], subschema)
|
|
i = i + 1
|
|
return key, field
|
|
end
|
|
end
|
|
|
|
|
|
--- Produce a string error message based on the table of errors
|
|
-- produced by the `validate` function.
|
|
-- @param errors The table of errors.
|
|
-- @return A string representing the errors, or nil if there
|
|
-- were no errors or a table was not given.
|
|
function Schema:errors_to_string(errors)
|
|
if not errors or type(errors) ~= "table" or not next(errors) then
|
|
return nil
|
|
end
|
|
|
|
local msgs = {}
|
|
|
|
-- General errors first
|
|
if errors["@entity"] then
|
|
for _, err in pairs(errors["@entity"]) do
|
|
insert(msgs, err)
|
|
end
|
|
end
|
|
|
|
for i = 1, #errors do
|
|
insert(msgs, errors[i])
|
|
end
|
|
|
|
-- Field-specific errors
|
|
for k, err in pairs(errors) do
|
|
if k ~= "@entity" then
|
|
if type(err) == "table" then
|
|
err = self:errors_to_string(err)
|
|
end
|
|
if type(k) == "string" then
|
|
insert(msgs, k..": "..err)
|
|
end
|
|
end
|
|
end
|
|
|
|
local summary = concat(msgs, "; ")
|
|
return validation_errors.ERROR:format(summary)
|
|
end
|
|
|
|
|
|
--- Given an entity, return a table containing only its primary key entries
|
|
-- @param entity a table mapping field names to their values
|
|
-- @return a subset of the input table, containing only the keys that
|
|
-- are part of the primary key for this schema.
|
|
function Schema:extract_pk_values(entity)
|
|
local pk_len = #self.primary_key
|
|
local pk_values = new_tab(0, pk_len)
|
|
|
|
for i = 1, pk_len do
|
|
local pk_name = self.primary_key[i]
|
|
pk_values[pk_name] = entity[pk_name]
|
|
end
|
|
|
|
return pk_values
|
|
end
|
|
|
|
|
|
--- Given a field of type `"foreign"`, returns the schema object for it.
|
|
-- @param field A field definition table
|
|
-- @return A schema object, or nil and an error message.
|
|
local function get_foreign_schema_for_field(field)
|
|
local ref = field.reference
|
|
|
|
local foreign_schema = _cache[ref] and _cache[ref].schema
|
|
if not foreign_schema then
|
|
return nil, validation_errors.SCHEMA_BAD_REFERENCE:format(ref)
|
|
end
|
|
|
|
return foreign_schema
|
|
end
|
|
|
|
|
|
function Schema:get_constraints()
|
|
if self.name == "workspaces" then
|
|
-- merge explicit and implicit constraints for workspaces
|
|
for _, e in pairs(_cache["workspaces"].constraints) do
|
|
local found = false
|
|
for i = 1, #_workspaceable do
|
|
if _workspaceable[i] == e then
|
|
found = true
|
|
break
|
|
end
|
|
end
|
|
if not found then
|
|
insert(_workspaceable, e)
|
|
end
|
|
end
|
|
return _workspaceable
|
|
end
|
|
|
|
local constraints = {}
|
|
for _, c in pairs(_cache[self.name].constraints) do
|
|
insert(constraints, c)
|
|
end
|
|
return constraints
|
|
end
|
|
|
|
|
|
local function allow_record_fields_by_name(record, loop)
|
|
loop = loop or {}
|
|
if loop[record] then
|
|
return
|
|
end
|
|
loop[record] = true
|
|
for k, f in Schema.each_field(record) do
|
|
record.fields[k] = f
|
|
if f.type == "record" and f.fields then
|
|
allow_record_fields_by_name(f, loop)
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
local function get_transform_args(input, original_input, output, transformation)
|
|
local args = {}
|
|
local argc = 0
|
|
for i = 1, #transformation.input do
|
|
local input_field_name = transformation.input[i]
|
|
local value = get_field(output or original_input or input, input_field_name)
|
|
if is_nonempty(value) then
|
|
argc = argc + 1
|
|
if original_input then
|
|
args[argc] = get_field(output or input, input_field_name)
|
|
else
|
|
args[argc] = value
|
|
end
|
|
|
|
else
|
|
return nil
|
|
end
|
|
end
|
|
|
|
if transformation.needs then
|
|
for i = 1, #transformation.needs do
|
|
local need = transformation.needs[i]
|
|
local value = get_field(output or input, need)
|
|
if is_nonempty(value) then
|
|
argc = argc + 1
|
|
args[argc] = get_field(output or input, need)
|
|
|
|
else
|
|
return nil
|
|
end
|
|
end
|
|
end
|
|
return args
|
|
end
|
|
|
|
|
|
local function run_transformations(self, transformations, input, original_input, context)
|
|
if self.type == "json" and context == "select" then
|
|
local decoded, err = safe_decode(input)
|
|
if err then
|
|
return nil, validation_errors.JSON_DECODE_ERROR:format(err)
|
|
end
|
|
input = decoded
|
|
end
|
|
|
|
local output
|
|
for i = 1, #transformations do
|
|
local transformation = transformations[i]
|
|
local transform
|
|
if context == "select" then
|
|
transform = transformation.on_read
|
|
|
|
else
|
|
transform = transformation.on_write
|
|
end
|
|
|
|
if transform then
|
|
if transformation.input or transformation.needs then
|
|
local args = get_transform_args(input, original_input, output, transformation)
|
|
if args then
|
|
local data, err = transform(unpack(args))
|
|
if err then
|
|
return nil, validation_errors.TRANSFORMATION_ERROR:format(err)
|
|
end
|
|
|
|
output = self:merge_values(data, output or input)
|
|
end
|
|
|
|
else
|
|
local data, err = transform(output or input)
|
|
if err then
|
|
return nil, validation_errors.TRANSFORMATION_ERROR:format(err)
|
|
end
|
|
|
|
output = self:merge_values(data, output or input)
|
|
end
|
|
end
|
|
end
|
|
|
|
return output or input
|
|
end
|
|
|
|
--- Check if the schema has transformation definitions.
|
|
-- @param input a table holding entities
|
|
-- @return a boolean value: 'true' or 'false'
|
|
function Schema:has_transformations(input)
|
|
if self.transformations then
|
|
return true
|
|
end
|
|
|
|
local subschema = get_subschema(self, input)
|
|
if subschema and subschema.transformations then
|
|
return true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
--- Run transformations on fields.
|
|
-- @param input The input table.
|
|
-- @param original_input The original input for transformation detection.
|
|
-- @param context a string describing the CRUD context:
|
|
-- valid values are: "insert", "update", "upsert", "select"
|
|
-- @return the transformed entity
|
|
function Schema:transform(input, original_input, context)
|
|
local output, err
|
|
if self.transformations then
|
|
output, err = run_transformations(self, self.transformations, input, original_input, context)
|
|
if not output then
|
|
return nil, err
|
|
end
|
|
end
|
|
|
|
local subschema = get_subschema(self, input)
|
|
if subschema and subschema.transformations then
|
|
output, err = run_transformations(subschema, subschema.transformations, output or input, original_input, context)
|
|
if not output then
|
|
return nil, err
|
|
end
|
|
end
|
|
|
|
return output or input
|
|
end
|
|
|
|
--- Instatiate a new schema from a definition.
|
|
-- @param definition A table with attributes describing
|
|
-- fields and other information about a schema.
|
|
-- @param is_subschema boolean, true if definition
|
|
-- is a subschema
|
|
-- @return The object implementing the schema matching
|
|
-- the given definition.
|
|
function Schema.new(definition, is_subschema)
|
|
if not definition then
|
|
return nil, validation_errors.SCHEMA_NO_DEFINITION
|
|
end
|
|
|
|
if not definition.fields then
|
|
return nil, validation_errors.SCHEMA_NO_FIELDS
|
|
end
|
|
|
|
local self = cycle_aware_deep_copy(definition)
|
|
setmetatable(self, Schema)
|
|
|
|
local cache_key = self.cache_key
|
|
if cache_key then
|
|
self.cache_key_set = {}
|
|
for i = 1, #cache_key do
|
|
self.cache_key_set[cache_key[i]] = true
|
|
end
|
|
end
|
|
|
|
for key, field in self:each_field() do
|
|
-- Also give access to fields by name
|
|
self.fields[key] = field
|
|
if field.type == "record" and field.fields then
|
|
allow_record_fields_by_name(field)
|
|
end
|
|
|
|
if field.type == "foreign" then
|
|
local err
|
|
field.schema, err = get_foreign_schema_for_field(field)
|
|
if not field.schema then
|
|
return nil, err
|
|
end
|
|
|
|
if not is_subschema then
|
|
-- Store the inverse relation for implementing constraints
|
|
local constraints = assert(_cache[field.reference]).constraints
|
|
-- Set logic to prevent duplicates when Schema is initialized multiple times
|
|
if self.name then
|
|
constraints[self.name] = {
|
|
schema = self,
|
|
field_name = key,
|
|
on_delete = field.on_delete,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if self.workspaceable and self.name then
|
|
if not _workspaceable[self.name] then
|
|
_workspaceable[self.name] = true
|
|
insert(_workspaceable, { schema = self })
|
|
end
|
|
end
|
|
|
|
if self.name then
|
|
-- do not reset the constraints list if a schema in reloaded
|
|
if not _cache[self.name] then
|
|
_cache[self.name] = {
|
|
constraints = {},
|
|
}
|
|
end
|
|
-- but always update the schema object in cache
|
|
_cache[self.name].schema = self
|
|
end
|
|
|
|
-- timestamp-irrelevant fields should not be a critical factor on entities to
|
|
-- be loaded or refreshed correctly. These fields, such as `ttl` and `updated_at`
|
|
-- might be ignored during validation.
|
|
-- unvalidated_fields is added for ignoring some fields, key in the table is the
|
|
-- name of the field to be ignored, the value must be a function, when the field
|
|
-- should be ignored, it returns true otherwise returns false.
|
|
self.unvalidated_fields = {
|
|
["ttl"] = function ()
|
|
return self.ttl
|
|
end,
|
|
["updated_at"] = function()
|
|
return true
|
|
end
|
|
}
|
|
|
|
setmetatable(self.unvalidated_fields, {
|
|
__index = function()
|
|
return function() -- default option
|
|
return false
|
|
end
|
|
end
|
|
})
|
|
|
|
|
|
return self
|
|
end
|
|
|
|
|
|
function Schema.new_subschema(self, key, definition)
|
|
assert(type(key) == "string", "key must be a string")
|
|
assert(type(definition) == "table", "definition must be a table")
|
|
|
|
if not self.subschema_key then
|
|
return nil, validation_errors.SUBSCHEMA_BAD_PARENT:format(key, self.name)
|
|
end
|
|
|
|
local subschema, err = Schema.new(definition, true)
|
|
if not subschema then
|
|
return nil, err
|
|
end
|
|
|
|
local parent_by_name = {}
|
|
for i = 1, #self.fields do
|
|
local fname, fdata = next(self.fields[i])
|
|
parent_by_name[fname] = fdata
|
|
end
|
|
|
|
for fname, field in subschema:each_field() do
|
|
local parent_field = parent_by_name[fname]
|
|
if not parent_field then
|
|
return nil, validation_errors.SUBSCHEMA_BAD_FIELD:format(key, fname)
|
|
end
|
|
if not compatible_fields(parent_field, field) then
|
|
return nil, validation_errors.SUBSCHEMA_BAD_TYPE:format(key, fname)
|
|
end
|
|
end
|
|
|
|
for fname, field in pairs(parent_by_name) do
|
|
if field.abstract and field.required and not subschema.fields[fname] then
|
|
return nil, validation_errors.SUBSCHEMA_UNDEFINED_FIELD:format(key, fname)
|
|
end
|
|
end
|
|
|
|
if not self.subschemas then
|
|
self.subschemas = {}
|
|
end
|
|
self.subschemas[key] = subschema
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
function Schema.define(tbl)
|
|
return setmetatable(tbl, {
|
|
__call = function(t, arg)
|
|
arg = arg or {}
|
|
for k,v in pairs(t) do
|
|
if arg[k] == nil then
|
|
arg[k] = v
|
|
end
|
|
end
|
|
return arg
|
|
end
|
|
})
|
|
end
|
|
|
|
|
|
return Schema
|