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

579 lines
15 KiB
Lua

local kong_table = require "kong.tools.table"
local cjson = require "cjson"
local pl_pretty = require "pl.pretty"
local tablex = require "pl.tablex"
local app_helpers = require "lapis.application"
local arguments = require "kong.api.arguments"
local Errors = require "kong.db.errors"
local hooks = require "kong.hooks"
local decode_args = require("kong.tools.http").decode_args
local ngx = ngx
local header = ngx.header
local sub = string.sub
local find = string.find
local type = type
local pairs = pairs
local ipairs = ipairs
local concat = table.concat
local get_method = ngx.req.get_method
local _M = {}
local NO_ARRAY_INDEX_MARK = {}
local HTTP_METHODS = {
["GET"] = true,
["HEAD"] = true,
["POST"] = true,
["PUT"] = true,
["DELETE"] = true,
["CONNECT"] = true,
["OPTIONS"] = true,
["TRACE"] = true,
["PATCH"] = true,
}
-- Parses a form value, handling multipart/data values
-- @param `v` The value object
-- @return The parsed value
local function parse_value(v)
return type(v) == "table" and v.content or v -- Handle multipart
end
-- given a string like "x[1].y", return an array of indices like {"x", 1, "y"}
-- the path parameter is an output-only param. the keys are added to it in order
local function key_to_path(key, path)
-- try to match an array access like x[1].
-- the left side of the [] is mandatory
-- the array index can be omitted (the key will look like x[]).
-- if that's the case we mark the path entry with a special key
local left, array_index = key:match("^(.+)%[(%d*)]$")
if left then
key_to_path(left, path)
path[#path + 1] = tonumber(array_index) or NO_ARRAY_INDEX_MARK
return path
end
-- if no match, try a hash access like x.y (both x and y are mandatory)
-- the left side of the dot is called left and the other side is right
local left, right = key:match("^(.+)%.(.+)$")
if left then
key_to_path(left, path)
key_to_path(right, path)
return path
end
-- if no match found, append the whole key to the path as a single string
path[#path + 1] = key
return path
end
-- when NO_ARRAY_INDEX is encountered, replace it with the length of the node being parsed
local function transform_no_array_index_mark(path_entry, node)
if path_entry == NO_ARRAY_INDEX_MARK then
return #node + 1
end
return path_entry
end
-- Put nested keys in objects:
-- Normalize dotted keys in objects.
-- Example: {["key.value.sub"]=1234} becomes {key = {value = {sub=1234}}
-- @param `obj` Object to normalize
-- @return `normalized_object`
function _M.normalize_nested_params(obj)
local new_obj = {}
local is_array
for k, v in pairs(obj) do
is_array = false
if type(v) == "table" then
-- normalize arrays since Lapis parses ?key[1]=foo as {["1"]="foo"} instead of {"foo"}
if kong_table.is_array(v, "lapis") then
is_array = true
local arr = {}
for _, arr_v in pairs(v) do arr[#arr+1] = arr_v end
v = arr
else
v = _M.normalize_nested_params(v) -- recursive call on other table values
end
end
v = parse_value(v)
-- normalize sub-keys with hash or array accesses
if type(k) == "string" then
local path = key_to_path(k, {})
local path_len = #path
local node = new_obj
local prev = new_obj
local path_entry
-- create any missing tables when dealing with x.foo[1].y = "bar"
for i = 1, path_len - 1 do
path_entry = transform_no_array_index_mark(path[i], node)
node[path_entry] = node[path_entry] or {}
prev = node
node = node[path_entry]
end
-- on the last item of the path (the "y" in the example above)
if path[path_len] == NO_ARRAY_INDEX_MARK and is_array then
-- edge case: we are assigning an array to a no-array index mark: x[] = {1,2,3}
-- on this case we backtrack one element (we use `prev` instead of `node`)
-- and we set it to the array (v)
-- this edge case is needed because Lapis builds params like that (flatten_params function)
if type(node) == "table" then
for i, v in ipairs(v) do
node[i] = v
end
else
prev[path_entry or k] = v
end
elseif type(node) == "table" then
-- regular case: the last element is similar to the loop iteration.
-- instead of a table, we set the value (v) on the last element
node[transform_no_array_index_mark(path[path_len], node)] = v
end
else
new_obj[k] = v -- nothing special with that key, simply attaching the value
end
end
return new_obj
end
-- Remove functions from a schema definition so that
-- cjson can encode the schema.
local schema_to_jsonable
do
local insert = table.insert
local ipairs = ipairs
local next = next
local fdata_to_jsonable
local function fields_to_jsonable(fields)
local out = {}
for _, field in ipairs(fields) do
local fname = next(field)
local fdata = field[fname]
insert(out, { [fname] = fdata_to_jsonable(fdata, "no") })
end
setmetatable(out, cjson.array_mt)
return out
end
-- Convert field data from schemas into something that can be
-- passed to a JSON encoder.
-- @tparam table fdata A Lua table with field data
-- @tparam string is_array A three-state enum: "yes", "no" or "maybe"
-- @treturn table A JSON-convertible Lua table
fdata_to_jsonable = function(fdata, is_array)
local out = {}
local iter = is_array == "yes" and ipairs or pairs
for k, v in iter(fdata) do
if is_array == "maybe" and type(k) ~= "number" then
is_array = "no"
end
if k == "schema" then
out[k] = schema_to_jsonable(v)
elseif type(v) == "table" then
if k == "fields" and fdata.type == "record" then
out[k] = fields_to_jsonable(v)
elseif k == "default" and fdata.type == "array" then
out[k] = fdata_to_jsonable(v, "yes")
else
out[k] = fdata_to_jsonable(v, "maybe")
end
elseif type(v) == "number" then
if v ~= v then
out[k] = "nan"
elseif v == math.huge then
out[k] = "inf"
elseif v == -math.huge then
out[k] = "-inf"
else
out[k] = v
end
elseif type(v) ~= "function" then
out[k] = v
end
end
if is_array == "yes" or is_array == "maybe" then
setmetatable(out, cjson.array_mt)
end
return out
end
schema_to_jsonable = function(schema)
local fields = fields_to_jsonable(schema.fields)
local entity_checks = fields_to_jsonable(schema.entity_checks or {})
return { entity_checks = entity_checks, fields = fields, }
end
_M.schema_to_jsonable = schema_to_jsonable
end
local NEEDS_BODY = tablex.readonly({ PUT = 1, POST = 2, PATCH = 3 })
local ACCEPTS_YAML = tablex.readonly({
["/config"] = true,
})
function _M.before_filter(self)
if not NEEDS_BODY[get_method()] then
return
end
local content_type = self.req.headers["content-type"]
if not content_type then
local content_length = self.req.headers["content-length"]
if content_length == "0" then
return
end
if not content_length then
local _, err = ngx.req.socket()
if err == "no body" then
return
end
end
elseif type(content_type) == "table" then
return kong.response.exit(400, "Multiple Content-Type headers not allowed")
elseif sub(content_type, 1, 16) == "application/json"
or sub(content_type, 1, 19) == "multipart/form-data"
or sub(content_type, 1, 33) == "application/x-www-form-urlencoded"
or (ACCEPTS_YAML[self.route_name] and
(sub(content_type, 1, 16) == "application/yaml" or
sub(content_type, 1, 9) == "text/yaml"))
then
return
end
return kong.response.exit(415)
end
function _M.cors_filter(self)
local allowed_origins = kong.configuration.admin_gui_origin
local function is_origin_allowed(req_origin)
for _, allowed_origin in ipairs(allowed_origins) do
if req_origin == allowed_origin then
return true
end
end
return false
end
local req_origin = self.req.headers["Origin"]
if allowed_origins and #allowed_origins > 0 then
if not is_origin_allowed(req_origin) then
req_origin = allowed_origins[1]
end
else
req_origin = req_origin or "*"
end
ngx.header["Access-Control-Allow-Origin"] = req_origin
ngx.header["Access-Control-Allow-Credentials"] = "true"
if ngx.req.get_method() == "OPTIONS" then
local request_allow_headers = self.req.headers["Access-Control-Request-Headers"]
if request_allow_headers then
ngx.header["Access-Control-Allow-Headers"] = request_allow_headers
end
end
end
local function parse_params(fn)
return app_helpers.json_params(function(self, ...)
local content_type = self.req.headers["content-type"]
local is_json
if NEEDS_BODY[get_method()] then
if content_type then
content_type = content_type:lower()
is_json = find(content_type, "application/json", 1, true)
if is_json and not self.json then
return kong.response.exit(400, { message = "Cannot parse JSON body" })
elseif find(content_type, "application/x-www-form-urlencode", 1, true) then
self.params = decode_args(self.params)
end
end
end
if not is_json then
self.params = _M.normalize_nested_params(self.params)
end
local res, err = fn(self, ...)
kong.worker_events.poll()
if err then
kong.log.err(err)
return ngx.exit(500)
end
if res == nil and ngx.status >= 200 then
return ngx.exit(0)
end
return res
end)
end
-- new DB
local function new_db_on_error(self)
local err = self.errors[1]
if type(err) ~= "table" then
kong.log.err(err)
return kong.response.exit(500, { message = "An unexpected error occurred" })
end
if err.strategy then
err.strategy = nil
end
local err_code = err.code
local codes = Errors.codes
if err_code == codes.SCHEMA_VIOLATION
or err_code == codes.INVALID_PRIMARY_KEY
or err_code == codes.FOREIGN_KEY_VIOLATION
or err_code == codes.INVALID_OFFSET
or err_code == codes.FOREIGN_KEYS_UNRESOLVED
then
return kong.response.exit(400, err)
end
if err_code == codes.NOT_FOUND then
return kong.response.exit(404, err)
end
if err_code == codes.OPERATION_UNSUPPORTED then
kong.log.err(err)
return kong.response.exit(405, err)
end
if err_code == codes.PRIMARY_KEY_VIOLATION
or err_code == codes.UNIQUE_VIOLATION
then
return kong.response.exit(409, err)
end
kong.log.err(err)
return kong.response.exit(500, { message = "An unexpected error occurred" })
end
-- old DAO
local function on_error(self)
local err = self.errors[1]
if type(err) ~= "table" then
kong.log.err(err)
return kong.response.exit(500, { message = "An unexpected error occurred" })
end
if err.name then
return new_db_on_error(self)
end
if err.db then
kong.log.err(err.message)
return kong.response.exit(500, { message = "An unexpected error occurred" })
end
if err.unique then
return kong.response.exit(409, err.tbl)
end
if err.foreign then
return kong.response.exit(404, err.tbl or { message = "Not found" })
end
return kong.response.exit(400, err.tbl or err.message)
end
local function options_method(methods)
return function()
kong.response.exit(204, nil, {
["Allow"] = header["Access-Control-Allow-Methods"] or methods,
["Access-Control-Allow-Methods"] = header["Access-Control-Allow-Methods"] or methods,
["Access-Control-Allow-Headers"] = header["Access-Control-Allow-Headers"] or "Content-Type"
})
end
end
local handler_helpers = {
yield_error = app_helpers.yield_error,
}
function _M.attach_routes(app, routes)
for route_path, methods in pairs(routes) do
methods.on_error = methods.on_error or on_error
local http_methods_array = {}
local http_methods_count = 0
for method_name, method_handler in pairs(methods) do
local wrapped_handler = function(self)
return method_handler(self, {}, handler_helpers)
end
methods[method_name] = parse_params(wrapped_handler)
if HTTP_METHODS[method_name] then
http_methods_count = http_methods_count + 1
http_methods_array[http_methods_count] = method_name
end
end
if not methods["HEAD"] and methods["GET"] then
methods["HEAD"] = methods["GET"]
http_methods_count = http_methods_count + 1
http_methods_array[http_methods_count] = "HEAD"
end
if not methods["OPTIONS"] then
http_methods_count = http_methods_count + 1
http_methods_array[http_methods_count] = "OPTIONS"
table.sort(http_methods_array)
methods["OPTIONS"] = options_method(concat(http_methods_array, ", ", 1, http_methods_count))
end
app:match(route_path, route_path, app_helpers.respond_to(methods))
assert(hooks.run_hook("api:helpers:attach_routes",
app, route_path, methods))
end
end
function _M.attach_new_db_routes(app, routes)
for route_path, definition in pairs(routes) do
local schema = definition.schema
local methods = definition.methods
methods.on_error = methods.on_error or new_db_on_error
local http_methods_array = {}
local http_methods_count = 0
for method_name, method_handler in pairs(methods) do
local wrapped_handler = function(self)
self.args = arguments.load({
schema = schema,
request = self.req,
})
return method_handler(self, kong.db, handler_helpers)
end
methods[method_name] = parse_params(wrapped_handler)
if HTTP_METHODS[method_name] then
http_methods_count = http_methods_count + 1
http_methods_array[http_methods_count] = method_name
end
end
if not methods["HEAD"] and methods["GET"] then
methods["HEAD"] = methods["GET"]
http_methods_count = http_methods_count + 1
http_methods_array[http_methods_count] = "HEAD"
end
if not methods["OPTIONS"] then
http_methods_count = http_methods_count + 1
http_methods_array[http_methods_count] = "OPTIONS"
table.sort(http_methods_array)
methods["OPTIONS"] = options_method(concat(http_methods_array, ", ", 1, http_methods_count))
end
app:match(route_path, route_path, app_helpers.respond_to(methods))
assert(hooks.run_hook("api:helpers:attach_new_db_routes",
app, route_path, methods))
end
end
function _M.default_route(self)
local path = self.req.parsed_url.path:match("^(.*)/$")
if path and self.app.router:resolve(path, self) then
return
elseif self.app.router:resolve(self.req.parsed_url.path .. "/", self) then
return
end
return self.app.handle_404(self)
end
function _M.handle_404(self)
return kong.response.exit(404, { message = "Not found" })
end
function _M.handle_error(self, err, trace)
if err then
if type(err) ~= "string" then
err = pl_pretty.write(err)
end
if find(err, "don't know how to respond to", nil, true) then
return kong.response.exit(405, { message = "Method not allowed" })
end
end
ngx.log(ngx.ERR, err, "\n", trace)
-- We just logged the error so no need to give it to responses and log it
-- twice
return kong.response.exit(500, { message = "An unexpected error occurred" })
end
function _M.is_new_db_routes(routes)
for _, verbs in pairs(routes) do
if type(verbs) == "table" then -- ignore "before" functions
return verbs.schema
end
end
end
return _M