579 lines
15 KiB
Lua
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
|