local pl_pretty = require("pl.pretty").write local pl_keys = require("pl.tablex").keys local nkeys = require("table.nkeys") local table_isarray = require("table.isarray") local uuid = require("kong.tools.uuid") local json = require("kong.tools.cjson") local type = type local null = ngx.null local log = ngx.log local WARN = ngx.WARN local error = error local upper = string.upper local fmt = string.format local pairs = pairs local ipairs = ipairs local tostring = tostring local setmetatable = setmetatable local getmetatable = getmetatable local concat = table.concat local sort = table.sort local insert = table.insert local remove = table.remove local new_array = json.new_array local sorted_keys = function(tbl) local keys = pl_keys(tbl) sort(keys) return keys end -- error codes local ERRORS = { INVALID_PRIMARY_KEY = 1, SCHEMA_VIOLATION = 2, PRIMARY_KEY_VIOLATION = 3, -- primary key already exists (HTTP 400) FOREIGN_KEY_VIOLATION = 4, -- foreign entity does not exist (HTTP 400) UNIQUE_VIOLATION = 5, -- unique key already exists (HTTP 409) NOT_FOUND = 6, -- WHERE clause leads nowhere (HTTP 404) INVALID_OFFSET = 7, -- page(size, offset) is invalid DATABASE_ERROR = 8, -- connection refused or DB error (HTTP 500) INVALID_SIZE = 9, -- page(size, offset) is invalid INVALID_UNIQUE = 10, -- unique field value is invalid INVALID_OPTIONS = 11, -- invalid options given OPERATION_UNSUPPORTED = 12, -- operation is not supported with this strategy FOREIGN_KEYS_UNRESOLVED = 13, -- foreign key(s) could not be resolved DECLARATIVE_CONFIG = 14, -- error parsing declarative configuration TRANSFORMATION_ERROR = 15, -- error with dao transformations INVALID_FOREIGN_KEY = 16, -- foreign key is valid for matching a row INVALID_WORKSPACE = 17, -- strategy reports a workspace error INVALID_UNIQUE_GLOBAL = 18, -- unique field value is invalid for global query REFERENCED_BY_OTHERS = 19, -- still referenced by other entities INVALID_SEARCH_QUERY = 20, -- ex. searched_field[unknown] = something -> 'unknown' is invalid (HTTP 400) SYNC_DELTAS = 21, -- error parsing sync deltas for sync.v2 } -- error codes messages local ERRORS_NAMES = { [ERRORS.INVALID_PRIMARY_KEY] = "invalid primary key", [ERRORS.SCHEMA_VIOLATION] = "schema violation", [ERRORS.PRIMARY_KEY_VIOLATION] = "primary key violation", [ERRORS.FOREIGN_KEY_VIOLATION] = "foreign key violation", [ERRORS.UNIQUE_VIOLATION] = "unique constraint violation", [ERRORS.NOT_FOUND] = "not found", [ERRORS.INVALID_OFFSET] = "invalid offset", [ERRORS.DATABASE_ERROR] = "database error", [ERRORS.INVALID_SIZE] = "invalid size", [ERRORS.INVALID_UNIQUE] = "invalid unique %s", [ERRORS.INVALID_OPTIONS] = "invalid options", [ERRORS.OPERATION_UNSUPPORTED] = "operation unsupported", [ERRORS.FOREIGN_KEYS_UNRESOLVED] = "foreign keys unresolved", [ERRORS.DECLARATIVE_CONFIG] = "invalid declarative configuration", [ERRORS.TRANSFORMATION_ERROR] = "transformation error", [ERRORS.INVALID_FOREIGN_KEY] = "invalid foreign key", [ERRORS.INVALID_WORKSPACE] = "invalid workspace", [ERRORS.INVALID_UNIQUE_GLOBAL] = "invalid global query", [ERRORS.REFERENCED_BY_OTHERS] = "referenced by others", [ERRORS.INVALID_SEARCH_QUERY] = "invalid search query", [ERRORS.SYNC_DELTAS] = "invalid sync deltas", } -- err_t metatable definition local _err_mt = { __tostring = function(err_t) local message = err_t.message if message == nil or message == null then message = err_t.name end if err_t.strategy then return fmt("[%s] %s", err_t.strategy, message) end return message end, __concat = function(a, b) return tostring(a) .. tostring(b) end, } -- error module local _M = { codes = ERRORS, names = ERRORS_NAMES, } local function new_err_t(self, code, message, errors, name) if type(message) == "table" and getmetatable(message) == _err_mt then return message end if not code then error("missing code") end if not ERRORS_NAMES[code] then error("unknown error code: " .. tostring(code)) end if message and type(message) ~= "string" then error("message must be a string or nil") end if errors and type(errors) ~= "table" then error("errors must be a table or nil") end local err_t = { code = code, name = name or ERRORS_NAMES[code], message = message or null, strategy = self.strategy, } if errors then local fields = {} for k, v in pairs(errors) do fields[k] = v end if code == ERRORS.INVALID_OPTIONS then err_t.options = fields else err_t.fields = fields end end return setmetatable(err_t, _err_mt) end function _M.__index(self, k) if ERRORS[k] then return ERRORS[k] end if _M[k] then return _M[k] end local upper_key = upper(k) if ERRORS[upper_key] then local f = function() return new_err_t(self, ERRORS[upper_key]) end self[k] = f return f end end function _M.new(strategy) local self = { strategy = strategy, } return setmetatable(self, _M) end function _M:invalid_primary_key(primary_key) if type(primary_key) ~= "table" then error("primary_key must be a table", 2) end local message = fmt("invalid primary key: '%s'", pl_pretty(primary_key, "")) return new_err_t(self, ERRORS.INVALID_PRIMARY_KEY, message, primary_key) end function _M:invalid_foreign_key(foreign_key) if type(foreign_key) ~= "table" then error("foreign_key must be a table", 2) end local message = fmt("invalid foreign key: '%s'", pl_pretty(foreign_key, "")) return new_err_t(self, ERRORS.INVALID_FOREIGN_KEY, message, foreign_key) end function _M:schema_violation(errors) if type(errors) ~= "table" then error("errors must be a table", 2) end local buf = {} local len = 0 if errors["@entity"] then for _, err in pairs(errors["@entity"]) do len = len + 1 buf[len] = err end end for _, field_name in ipairs(sorted_keys(errors)) do if field_name ~= "@entity" then local field_errors = errors[field_name] if type(field_errors) == "table" then for _, sub_field in ipairs(sorted_keys(field_errors)) do len = len + 1 local value = field_errors[sub_field] if type(value) == "table" then value = pl_pretty(value) end buf[len] = fmt("%s.%s: %s", field_name, sub_field, value) end else len = len + 1 buf[len] = fmt("%s: %s", field_name, field_errors) end end end local message if len == 1 then message = fmt("schema violation (%s)", buf[1]) else message = fmt("%d schema violations (%s)", len, concat(buf, "; ")) end return new_err_t(self, ERRORS.SCHEMA_VIOLATION, message, errors) end function _M:primary_key_violation(primary_key) if type(primary_key) ~= "table" then error("primary_key must be a table", 2) end local message = fmt("primary key violation on key '%s'", pl_pretty(primary_key, "")) return new_err_t(self, ERRORS.PRIMARY_KEY_VIOLATION, message, primary_key) end function _M:foreign_key_violation_invalid_reference(foreign_key, foreign_key_field_name, parent_name) if type(foreign_key) ~= "table" then error("foreign_key must be a table", 2) end if type(foreign_key_field_name) ~= "string" then error("foreign_key_field_name must be a string", 2) end if type(parent_name) ~= "string" then error("parent_name must be a string", 2) end local message = fmt("the foreign key '%s' does not reference an existing '%s' entity.", pl_pretty(foreign_key, ""), parent_name) return new_err_t(self, ERRORS.FOREIGN_KEY_VIOLATION, message, { [foreign_key_field_name] = foreign_key }) end function _M:foreign_key_violation_restricted(parent_name, child_name) if type(parent_name) ~= "string" then error("parent_name must be a string", 2) end if type(child_name) ~= "string" then error("child_name must be a string", 2) end local message = fmt("an existing '%s' entity references this '%s' entity", child_name, parent_name) return new_err_t(self, ERRORS.FOREIGN_KEY_VIOLATION, message, { ["@referenced_by"] = child_name }) end function _M:foreign_keys_unresolved(errors) if type(errors) ~= "table" then error("errors must be a table", 2) end local buf = {} local len = 0 for _, field_name in ipairs(sorted_keys(errors)) do local field_errors = errors[field_name] if type(field_errors) == "table" then for _, sub_field in ipairs(sorted_keys(field_errors)) do len = len + 1 local value = field_errors[sub_field] if type(value) == "table" then value = fmt("the foreign key cannot be resolved with '%s' for an existing '%s' entity", pl_pretty({ [value.name] = value.value }, ""), value.parent) end field_errors[sub_field] = value buf[len] = fmt("%s.%s: %s", field_name, sub_field, value) end else len = len + 1 buf[len] = fmt("%s: %s", field_name, field_errors) end end local message if len == 1 then message = fmt("foreign key unresolved (%s)", buf[1]) else message = fmt("%d foreign keys unresolved (%s)", len, concat(buf, "; ")) end return new_err_t(self, ERRORS.FOREIGN_KEYS_UNRESOLVED, message, errors) end function _M:unique_violation(unique_key) if type(unique_key) ~= "table" then error("unique_key must be a table", 2) end local message = fmt("UNIQUE violation detected on '%s'", pl_pretty(unique_key, ""):gsub("\"userdata: NULL\"", "null")) return new_err_t(self, ERRORS.UNIQUE_VIOLATION, message, unique_key) end function _M:not_found(primary_key) if type(primary_key) ~= "table" then error("primary_key must be a table", 2) end local message = fmt("could not find the entity with primary key '%s'", pl_pretty(primary_key, "")) return new_err_t(self, ERRORS.NOT_FOUND, message, primary_key) end function _M:not_found_by_field(filter) if type(filter) ~= "table" then error("filter must be a table", 2) end local message = fmt("could not find the entity with '%s'", pl_pretty(filter, "")) return new_err_t(self, ERRORS.NOT_FOUND, message, filter) end function _M:invalid_offset(offset, err) if type(offset) ~= "string" then error("offset must be a string", 2) end if type(err) ~= "string" then error("err must be a string", 2) end local message = fmt("'%s' is not a valid offset: %s", offset, err) return new_err_t(self, ERRORS.INVALID_OFFSET, message) end function _M:database_error(err) err = err or ERRORS_NAMES[ERRORS.DATABASE_ERROR] return new_err_t(self, ERRORS.DATABASE_ERROR, err) end function _M:transformation_error(err) err = err or ERRORS_NAMES[ERRORS.TRANSFORMATION_ERROR] return new_err_t(self, ERRORS.TRANSFORMATION_ERROR, err) end function _M:invalid_size(err) if type(err) ~= "string" then error("err must be a string", 2) end return new_err_t(self, ERRORS.INVALID_SIZE, err) end function _M:invalid_unique(name, err) if type(err) ~= "string" then error("err must be a string", 2) end return new_err_t(self, ERRORS.INVALID_UNIQUE, err, nil, fmt(ERRORS_NAMES[ERRORS.INVALID_UNIQUE], name)) end function _M:invalid_options(errors) if type(errors) ~= "table" then error("errors must be a table", 2) end local buf = {} local len = 0 for _, option_name in ipairs(sorted_keys(errors)) do local option_errors = errors[option_name] if type(option_errors) == "table" then for _, sub_option in ipairs(sorted_keys(option_errors)) do len = len + 1 buf[len] = fmt("%s.%s: %s", option_name, sub_option, option_errors[sub_option]) end else len = len + 1 buf[len] = fmt("%s: %s", option_name, option_errors) end end local message if len == 1 then message = fmt("invalid option (%s)", buf[1]) else message = fmt("%d option violations (%s)", len, concat(buf, "; ")) end return new_err_t(self, ERRORS.INVALID_OPTIONS, message, errors) end function _M:operation_unsupported(err) if type(err) ~= "string" then error("err must be a string", 2) end return new_err_t(self, ERRORS.OPERATION_UNSUPPORTED, err) end function _M:declarative_config(err_t) if type(err_t) ~= "table" then error("err_t must be a table", 2) end local message = fmt("declarative config is invalid: %s", pl_pretty(err_t, "")) return new_err_t(self, ERRORS.DECLARATIVE_CONFIG, message, err_t) end function _M:sync_deltas(err_t) if type(err_t) ~= "table" then error("err_t must be a table", 2) end local message = fmt("sync deltas is invalid: %s", pl_pretty(err_t, "")) return new_err_t(self, ERRORS.SYNC_DELTAS, message, err_t) end function _M:invalid_workspace(ws_id) if type(ws_id) ~= "string" then error("ws_id must be a string", 2) end local message = fmt("invalid workspace '%s'", ws_id) return new_err_t(self, ERRORS.INVALID_WORKSPACE, message) end function _M:invalid_unique_global(name) if type(name) ~= "string" then error("name must be a string", 2) end return new_err_t(self, ERRORS.INVALID_UNIQUE_GLOBAL, fmt("unique key %s is invalid for global query", name)) end function _M:referenced_by_others(err) if type(err) ~= "string" then error("err must be a string", 2) end return new_err_t(self, ERRORS.REFERENCED_BY_OTHERS, err) end local flatten_errors do local function singular(noun) if noun:sub(-1) == "s" then return noun:sub(1, -2) end return noun end local function join(ns, field) if type(ns) == "string" and ns ~= "" then return ns .. "." .. field end return field end local function is_array(v) return type(v) == "table" and table_isarray(v) end local each_foreign_field do ---@type table local relationships -- for each known entity, build a table of other entities which may -- reference it via a foreign key relationship as well as any of its -- own foreign key relationships. local function build_relationships() relationships = setmetatable({}, { __index = function(self, k) local t = {} rawset(self, k, t) return t end, }) for entity, dao in pairs(kong.db.daos) do for fname, field in dao.schema:each_field() do if field.type == "foreign" then insert(relationships[entity], { field = fname, entity = entity, reference = field.reference, }) -- create a backref for entities that may be nested under their -- foreign key reference entity (one-to-many relationships) -- -- example: services and routes -- -- route.service = { type = "foreign", reference = "services" } -- -- insert(relationships.services, { -- field = "service", -- entity = "routes", -- reference = "services", -- }) -- insert(relationships[field.reference], { field = fname, entity = entity, reference = field.reference, }) end end end end local empty = function() end ---@param entity_type string ---@return fun():{ field:string, entity:string, reference:string }? iterator function each_foreign_field(entity_type) -- this module is require()-ed before the kong global is initialized, so -- the lookup table of relationships needs to be built lazily if not relationships then build_relationships() end local fields = relationships[entity_type] if not fields then return empty end local i = 0 return function() i = i + 1 return fields[i] end end end ---@param err table|string ---@param flattened table local function add_entity_error(err, flattened) if type(err) == "table" then for _, message in ipairs(err) do add_entity_error(message, flattened) end else insert(flattened, { type = "entity", message = err, }) end end ---@param field string ---@param err table|string ---@param flattened table local function add_field_error(field, err, flattened) if type(err) == "table" then for _, message in ipairs(err) do add_field_error(field, message, flattened) end else insert(flattened, { type = "field", field = field, message = err, }) end end ---@param state { t:table, [integer]:any } ---@return any? key ---@return any? value local function drain_iter(state) local key = remove(state) if key == nil then return end local t = state.t local value = t[key] -- the value was removed from the table during iteration if value == nil then -- skip to the next key return drain_iter(state) end t[key] = nil return key, value end --- Iterate over the elements in a table while removing them. --- --- The original table is not used for iteration state. Instead, state is --- kept in a separate table along with a copy of the table's keys. This --- means that it is safe to mutate the original table during iteration. --- --- Any items explicitly removed (`original_table[key] = nil`) from the --- original table during iteration will be skipped. --- ---@generic K, V ---@param t table ---@return fun():K?, V iterator ---@return table state local function drain(t) local state = { t = t, } local n = 0 for k in pairs(t) do n = n + 1 state[n] = k end -- not sure if really necessary, but the consistency probably doesn't hurt sort(state) return drain_iter, state end ---@param errs table ---@param ns? string ---@param flattened? table local function categorize_errors(errs, ns, flattened) flattened = flattened or new_array() for field, err in drain(errs) do local errtype = type(err) if field == "@entity" then add_entity_error(err, flattened) elseif errtype == "string" then add_field_error(join(ns, field), err, flattened) elseif errtype == "table" then categorize_errors(err, join(ns, field), flattened) else log(WARN, "unknown error type: ", errtype, " at key: ", field) end end return flattened end ---@param name any ---@return string|nil local function validate_name(name) return (type(name) == "string" and name:len() > 0 and name) or nil end ---@param id any ---@return string|nil local function validate_id(id) return (type(id) == "string" and uuid.is_valid_uuid(id) and id) or nil end ---@param tags any ---@return string[]|nil local function validate_tags(tags) if type(tags) == "table" and is_array(tags) then for i = 1, #tags do if type(tags[i]) ~= "string" then return end end return tags end end -- given an entity table with an .id attribute that is a valid UUID, -- yield a primary key table -- ---@param entity table ---@return table? local function make_pk(entity) if validate_id(entity.id) then return { id = entity.id } end end ---@param entity_type string ---@param entity table ---@param err_t table ---@param flattened table local function add_entity_errors(entity_type, entity, err_t, flattened) local err_type = type(err_t) -- promote error strings to `@entity` type errors if err_type == "string" then err_t = { ["@entity"] = err_t } elseif err_type ~= "table" or nkeys(err_t) == 0 then return end -- this *should* be unreachable, but it's relatively cheap to guard against -- compared to everything else we're doing in this code path if type(entity) ~= "table" then log(WARN, "could not parse ", entity_type, " errors for non-table ", "input: '", tostring(entity), "'") return end local entity_pk = make_pk(entity) -- promote errors for foreign key relationships up to the top level -- array of errors and recursively flatten any of their validation -- errors for ref in each_foreign_field(entity_type) do -- owned one-to-one relationship -- -- Example: -- -- entity_type => "services" -- -- entity => { -- name = "my-invalid-service", -- url = "https://localhost:1234" -- client_certificate = { -- id = "d2e33f63-1424-408f-be55-d9d16cd2a382", -- cert = "bad cert data", -- key = "bad cert key data", -- } -- } -- -- ref => { -- entity = "services", -- field = "client_certificate", -- reference = "certificates" -- } -- -- field_name => "client_certificate" -- -- field_entity_type => "certificates" -- -- field_value => { -- id = "d2e33f63-1424-408f-be55-d9d16cd2a382", -- cert = "bad cert data", -- key = "bad cert key data", -- } -- -- because the client certificate entity has a valid ID, we store a -- reference to it as a primary key on our entity table: -- -- entity => { -- name = "my-invalid-service", -- url = "https://localhost:1234" -- client_certificate = { -- id = "d2e33f63-1424-408f-be55-d9d16cd2a382", -- } -- } -- if ref.entity == entity_type then local field_name = ref.field local field_value = entity[field_name] local field_entity_type = ref.reference local field_err_t = err_t[field_name] -- if the foreign value is _not_ a table, attempting to treat it like -- an entity or array of entities will only yield confusion. -- -- instead, it's better to leave the error intact so that it will be -- categorized as a field error on the current entity if type(field_value) == "table" then entity[field_name] = make_pk(field_value) err_t[field_name] = nil add_entity_errors(field_entity_type, field_value, field_err_t, flattened) end -- foreign one-to-many relationship -- -- Example: -- -- entity_type => "routes" -- -- entity => { -- name = "my-invalid-route", -- id = "d2e33f63-1424-408f-be55-d9d16cd2a382", -- paths = { 123 }, -- plugins = { -- { -- name = "http-log", -- config = { -- invalid_param = 456, -- }, -- }, -- { -- name = "file-log", -- config = { -- invalid_param = 456, -- }, -- }, -- }, -- } -- -- ref => { -- entity = "plugins", -- field = "route", -- reference = "routes" -- } -- -- field_name => "plugins" -- -- field_entity_type => "plugins" -- -- field_value => { -- { -- name = "http-log", -- config = { -- invalid_param = 456, -- }, -- }, -- { -- name = "file-log", -- config = { -- invalid_param = 456, -- }, -- }, -- } -- -- before recursing on each plugin in `entity.plugins` to handle their -- respective validation errors, we add our route's primary key to them, -- yielding: -- -- { -- { -- name = "http-log", -- config = { -- invalid_param = 456, -- }, -- route = { -- id = "d2e33f63-1424-408f-be55-d9d16cd2a382", -- }, -- }, -- { -- name = "file-log", -- config = { -- invalid_param = 456, -- }, -- route = { -- id = "d2e33f63-1424-408f-be55-d9d16cd2a382", -- }, -- }, -- } -- else local field_name = ref.entity local field_value = entity[field_name] local field_entity_type = field_name local field_err_t = err_t[field_name] local field_fk = ref.field -- same as the one-to-one case: if the field's value is not a table, -- we will let any errors related to it be categorized as a field-level -- error instead if type(field_value) == "table" then entity[field_name] = nil err_t[field_name] = nil if field_err_t then for i = 1, #field_value do local item = field_value[i] -- add our entity's primary key to each child item if item[field_fk] == nil then item[field_fk] = entity_pk end add_entity_errors(field_entity_type, item, field_err_t[i], flattened) end end end end end -- all of our errors were related to foreign relationships; -- nothing left to do if nkeys(err_t) == 0 then return end local entity_errors = categorize_errors(err_t) if #entity_errors > 0 then insert(flattened, { -- entity_id, entity_name, and entity_tags must be validated to ensure -- that the response is well-formed. They are also optional, so we will -- simply leave them out if they are invalid. -- -- The nested entity object itself will retain the original, untouched -- values for these fields. entity_name = validate_name(entity.name), entity_id = validate_id(entity.id), entity_tags = validate_tags(entity.tags), entity_type = singular(entity_type), entity = entity, errors = entity_errors, }) else log(WARN, "failed to categorize errors for ", entity_type, ", ", entity.name or entity.id) end end ---@param err_t table ---@param input table ---@return table function flatten_errors(input, err_t) local flattened = new_array() for entity_type, section_errors in drain(err_t) do if type(section_errors) ~= "table" then -- don't flatten it; just put it back err_t[entity_type] = section_errors goto next_section end local entities = input[entity_type] if type(entities) ~= "table" then -- put it back into the error table err_t[entity_type] = section_errors goto next_section end for i, err_t_i in drain(section_errors) do local entity = entities[i] if type(entity) == "table" then add_entity_errors(entity_type, entity, err_t_i, flattened) else log(WARN, "failed to resolve errors for ", entity_type, " at ", "index '", i, "'") section_errors[i] = err_t_i end end ::next_section:: end return flattened end end -- traverse declarative schema validation errors and correlate them with -- objects/entities from the original user input -- -- Produces a list of errors with the following format: -- -- ```lua -- { -- entity_type = "service", -- service, route, plugin, etc -- entity_id = "", -- useful to correlate errors across fk relationships -- entity_name = "my-service", -- may be nil -- entity_tags = { "my-tag" }, -- entity = { -- the full entity object -- name = "my-service", -- id = "", -- tags = { "my-tag" }, -- host = "127.0.0.1", -- protocol = "tcp", -- path = "/path", -- }, -- errors = { -- { -- type = "entity" -- message = "failed conditional validation given value of field 'protocol'", -- }, -- { -- type = "field" -- field = "path", -- message = "value must be null", -- } -- } -- } -- ``` -- -- Nested foreign relationships are hoisted up to the top level, so -- given the following input: -- -- ```lua -- { -- services = { -- name = "matthew", -- url = "http:/127.0.0.1:80/", -- routes = { -- { -- name = "joshua", -- protocols = { "nope" }, -- invalid protocol -- } -- }, -- plugins = { -- { -- name = "i-am-not-a-real-plugin", -- nonexistent plugin -- config = { -- foo = "bar", -- }, -- }, -- { -- name = "http-log", -- config = {}, -- missing required field(s) -- }, -- }, -- } -- } -- ``` -- ... the output error array will have three entries, one for the route, -- and one for each of the plugins. -- -- Errors for record fields and nested schema properties are rolled up and -- added to their parent entity, with the full path to the property -- represented as a period-delimited string: -- -- ```lua -- { -- entity_type = "plugin", -- entity_name = "http-log", -- entity = { -- name = "http-log", -- config = { -- -- empty -- }, -- }, -- errors = { -- { -- field = "config.http_endpoint", -- message = "missing host in url", -- type = "field" -- } -- }, -- } -- ``` -- ---@param err_t table ---@param input table ---@return table function _M:declarative_config_flattened(err_t, input) if type(err_t) ~= "table" then error("err_t must be a table", 2) end if type(input) ~= "table" then error("err_t input is nil or not a table", 2) end local flattened = flatten_errors(input, err_t) err_t = self:declarative_config(err_t) err_t.flattened_errors = flattened return err_t end -- traverse schema validation errors and correlate them with objects/entities -- which does not pass delta validation for sync.v2 -- ---@param err_t table ---@param err_entities table ---@return table function _M:sync_deltas_flattened(err_t, err_entities) if type(err_t) ~= "table" then error("err_t must be a table", 2) end if type(err_entities) ~= "table" then error("err_entities is nil or not a table", 2) end local flattened = flatten_errors(err_entities, err_t) err_t = self:sync_deltas(err_t) err_t.flattened_errors = flattened return err_t end return _M