Skip to content

Commit

Permalink
feat(aws-lambda) support for Lambda Proxy Integration
Browse files Browse the repository at this point in the history
Summary
-------

Previously, Kong would return a 200 or 202 no matter what the Lambda
function responded with.

Now, a Lambda function can now return an object that sets a custom HTTP
status code, headers, and body. Kong will use these values to override
the response sent back to the client.

This new behavior brings Kong to parity with AWS API Gateway and will
make migrating from that platform much easier.

Read more on Lambda Proxy Integration here:

https://docs.aws.amazon.com/apigateway/latest/developerguide/getting-started-with-lambda-integration.html

The supported response format from the Lamda is:

    {
      "statusCode": 201,
      "headers": { "headerName": "headerValue", ... },
      "body": "..."
    }

Changes
-------

* Add `is_proxy_integration` option to schema (disabled by default).
  When enabled, the behavior described above will be in effect.
* Invalid response formats will result in a 502 response from Kong.
* Add test cases for returning a custom response from
  Lambda.

NYI
---

Not yet implemented:

* Support for `isBase64Encoded`

See
---

From #3427

Signed-off-by: Thibault Charbonnier <[email protected]>
  • Loading branch information
aloisbarreras authored and thibaultcha committed Sep 25, 2018
1 parent 6257b24 commit c6f9e45
Show file tree
Hide file tree
Showing 4 changed files with 313 additions and 7 deletions.
76 changes: 70 additions & 6 deletions kong/plugins/aws-lambda/handler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ local public_utils = require "kong.tools.public"


local tostring = tostring
local tonumber = tonumber
local pairs = pairs
local type = type
local fmt = string.format
Expand All @@ -38,6 +39,54 @@ local server_header = meta._SERVER_TOKENS
local AWS_PORT = 443


--[[
Response format should be
{
"statusCode": httpStatusCode,
"headers": { "headerName": "headerValue", ... },
"body": "..."
}
--]]
local function validate_custom_response(response)
if type(response.statusCode) ~= "number" then
return nil, "statusCode must be a number"
end

if response.headers ~= nil and type(response.headers) ~= "table" then
return nil, "headers must be a table"
end

if response.body ~= nil and type(response.body) ~= "string" then
return nil, "body must be a string"
end

return true
end


local function extract_proxy_response(content)
local serialized_content, err = cjson.decode(content)
if not serialized_content then
return nil, err
end

local ok, err = validate_custom_response(serialized_content)
if not ok then
return nil, err
end

local headers = serialized_content.headers or {}
local body = serialized_content.body or ""
headers["Content-Length"] = #body

return {
status_code = tonumber(serialized_content.statusCode),
body = body,
headers = headers,
}
end


local function send(status, content, headers)
ngx.status = status

Expand Down Expand Up @@ -187,13 +236,28 @@ function AWSLambdaHandler:access(conf)
end

local status
if conf.unhandled_status
and headers["X-Amz-Function-Error"] == "Unhandled"
then
status = conf.unhandled_status

else
status = res.status
if conf.is_proxy_integration then
local proxy_response, err = extract_proxy_response(content)
if not proxy_response then
return responses.send_HTTP_BAD_GATEWAY("could not JSON decode Lambda " ..
"function response: " .. err)
end

status = proxy_response.status_code
headers = utils.table_merge(headers, proxy_response.headers)
content = proxy_response.body
end

if not status then
if conf.unhandled_status
and headers["X-Amz-Function-Error"] == "Unhandled"
then
status = conf.unhandled_status

else
status = res.status
end
end

local ctx = ngx.ctx
Expand Down
4 changes: 4 additions & 0 deletions kong/plugins/aws-lambda/schema.lua
Original file line number Diff line number Diff line change
Expand Up @@ -97,5 +97,9 @@ return {
type = "boolean",
default = false,
},
is_proxy_integration = {
type = "boolean",
default = false,
},
},
}
228 changes: 228 additions & 0 deletions spec/03-plugins/23-aws-lambda/01-access_spec.lua
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
local cjson = require "cjson"
local helpers = require "spec.helpers"
local meta = require "kong.meta"

Expand Down Expand Up @@ -67,6 +68,36 @@ for _, strategy in helpers.each_strategy() do
service = service10
}

local service11 = bp.services:insert({
protocol = "http",
host = "httpbin.org",
port = 80,
})

local route11 = bp.routes:insert {
hosts = { "lambda11.com" },
protocols = { "http", "https" },
service = service11
}

local service12 = bp.services:insert({
protocol = "http",
host = "httpbin.org",
port = 80,
})

local route12 = bp.routes:insert {
hosts = { "lambda12.com" },
protocols = { "http", "https" },
service = service12
}

local route13 = bp.routes:insert {
hosts = { "lambda13.com" },
protocols = { "http", "https" },
service = service12,
}

bp.plugins:insert {
name = "aws-lambda",
route_id = route1.id,
Expand Down Expand Up @@ -201,6 +232,45 @@ for _, strategy in helpers.each_strategy() do
}
}

bp.plugins:insert {
name = "aws-lambda",
route_id = route11.id,
config = {
port = 10001,
aws_key = "mock-key",
aws_secret = "mock-secret",
aws_region = "us-east-1",
function_name = "kongLambdaTest",
is_proxy_integration = true,
}
}

bp.plugins:insert {
name = "aws-lambda",
route_id = route12.id,
config = {
port = 10001,
aws_key = "mock-key",
aws_secret = "mock-secret",
aws_region = "us-east-1",
function_name = "functionWithBadJSON",
is_proxy_integration = true,
}
}

bp.plugins:insert {
name = "aws-lambda",
route_id = route13.id,
config = {
port = 10001,
aws_key = "mock-key",
aws_secret = "mock-secret",
aws_region = "us-east-1",
function_name = "functionWithNoResponse",
is_proxy_integration = true,
}
}

assert(helpers.start_kong{
database = strategy,
nginx_conf = "spec/fixtures/custom_nginx.template",
Expand Down Expand Up @@ -527,5 +597,163 @@ for _, strategy in helpers.each_strategy() do
assert.equal(65, tonumber(res.headers["Content-Length"]))
end)


describe("config.is_proxy_integration = true", function()
it("sets proper status code on custom response from Lambda", function()
local res = assert(proxy_client:send {
method = "POST",
path = "/post",
headers = {
["Host"] = "lambda11.com",
["Content-Type"] = "application/json"
},
body = {
statusCode = 201,
}
})
local body = assert.res_status(201, res)
assert.equal(0, tonumber(res.headers["Content-Length"]))
assert.equal(nil, res.headers["X-Custom-Header"])
assert.equal("", body)
end)

it("sets proper status code/headers/body on custom response from Lambda", function()
-- the lambda function must return a string
-- for the custom response "body" property
local body = cjson.encode({
key1 = "some_value_post1",
key2 = "some_value_post2",
key3 = "some_value_post3",
})

local res = assert(proxy_client:send {
method = "POST",
path = "/post",
headers = {
["Host"] = "lambda11.com",
["Content-Type"] = "application/json",
},
body = {
statusCode = 201,
body = body,
headers = {
["X-Custom-Header"] = "Hello world!"
}
}
})

local res_body = assert.res_status(201, res)
assert.equal(79, tonumber(res.headers["Content-Length"]))
assert.equal("Hello world!", res.headers["X-Custom-Header"])
assert.equal(body, res_body)
end)

it("override duplicated headers with value from the custom response from Lambda", function()
-- the default "x-amzn-RequestId" returned is "foo"
-- let's check it is overriden with a custom value
local headers = {
["x-amzn-RequestId"] = "bar",
}

local res = assert(proxy_client:send {
method = "POST",
path = "/post",
headers = {
["Host"] = "lambda11.com",
["Content-Type"] = "application/json",
},
body = {
statusCode = 201,
headers = headers,
}
})

assert.res_status(201, res)
assert.equal("bar", res.headers["x-amzn-RequestId"])
end)

it("returns HTTP 502 when 'status' property of custom response is not a number", function()
local res = assert(proxy_client:send {
method = "POST",
path = "/post",
headers = {
["Host"] = "lambda11.com",
["Content-Type"] = "application/json",
},
body = {
statusCode = "hello",
}
})

assert.res_status(502, res)
local b = assert.response(res).has.jsonbody()
assert.equal("Bad Gateway", b.message)
end)

it("returns HTTP 502 when 'headers' property of custom response is not a table", function()
local res = assert(proxy_client:send {
method = "POST",
path = "/post",
headers = {
["Host"] = "lambda11.com",
["Content-Type"] = "application/json",
},
body = {
headers = "hello",
}
})

assert.res_status(502, res)
local b = assert.response(res).has.jsonbody()
assert.equal("Bad Gateway", b.message)
end)

it("returns HTTP 502 when 'body' property of custom response is not a string", function()
local res = assert(proxy_client:send {
method = "POST",
path = "/post",
headers = {
["Host"] = "lambda11.com",
["Content-Type"] = "application/json",
},
body = {
statusCode = 201,
body = 1234,
}
})

assert.res_status(502, res)
local b = assert.response(res).has.jsonbody()
assert.equal("Bad Gateway", b.message)
end)

it("returns HTTP 502 with when response from lambda is not valid JSON", function()
local res = assert(proxy_client:send {
method = "POST",
path = "/post",
headers = {
["Host"] = "lambda12.com",
}
})

assert.res_status(502, res)
local b = assert.response(res).has.jsonbody()
assert.equal("Bad Gateway", b.message)
end)

it("returns HTTP 502 on empty response from Lambda", function()
local res = assert(proxy_client:send {
method = "POST",
path = "/post",
headers = {
["Host"] = "lambda13.com",
}
})

assert.res_status(502, res)
local b = assert.response(res).has.jsonbody()
assert.equal("Bad Gateway", b.message)
end)
end)
end)
end
12 changes: 11 additions & 1 deletion spec/fixtures/custom_nginx.template
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,22 @@ http {
content_by_lua_block {
local function say(res, status)
ngx.header["x-amzn-RequestId"] = "foo"

if string.match(ngx.var.uri, "functionWithUnhandledError") then
ngx.header["X-Amz-Function-Error"] = "Unhandled"
end

ngx.status = status

if type(res) == 'string' then
if string.match(ngx.var.uri, "functionWithBadJSON") then
local badRes = "{\"foo\":\"bar\""
ngx.header["Content-Length"] = #badRes + 1
ngx.say(badRes)

elseif string.match(ngx.var.uri, "functionWithNoResponse") then
ngx.header["Content-Length"] = 0

elseif type(res) == 'string' then
ngx.header["Content-Length"] = #res + 1
ngx.say(res)

Expand Down

0 comments on commit c6f9e45

Please sign in to comment.