Files
kong--kong/kong/db/errors.lua
T
2026-07-13 12:32:21 +08:00

1216 lines
31 KiB
Lua

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<string, { field:string, entity:string, reference:string }[]>
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<K, V>
---@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 = "<uuid>", -- 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 = "<uuid>",
-- 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