From 020de3ed6f70c9f5cf3c052345a679f4ff94f0e2 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Tue, 4 Apr 2017 10:32:37 +0200 Subject: [PATCH] feat(plugin) request termination (#2328, #2051) * feat(plugin): request-termination A new plug-in that allows a request to be terminated and a specified HTTP status code and body returned. This is useful to temporarily return a status page for a service. For example if the service is unavailable due to scheduled maintenance. --- CHANGELOG.md | 7 + kong-0.10.1-0.rockspec | 3 + kong/constants.lua | 34 +++- kong/plugins/request-termination/handler.lua | 39 ++++ kong/plugins/request-termination/schema.lua | 31 +++ kong/tools/responses.lua | 17 +- spec/01-unit/09-responses_spec.lua | 19 +- .../27-request-termination/01-schema_spec.lua | 57 ++++++ .../27-request-termination/02-access_spec.lua | 177 ++++++++++++++++++ 9 files changed, 372 insertions(+), 12 deletions(-) create mode 100644 kong/plugins/request-termination/handler.lua create mode 100644 kong/plugins/request-termination/schema.lua create mode 100644 spec/03-plugins/27-request-termination/01-schema_spec.lua create mode 100644 spec/03-plugins/27-request-termination/02-access_spec.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index e7692eeb11b3..601dd3a0b86b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## [Unreleased][unreleased] +### Added + +- Request termination plugin. Allowing to terminate a request with a custom + status and message. Thanks to [Paul Austin](https://github.com/pauldaustin) + for the contribution. + [#2051](https://github.com/Mashape/kong/pull/2051) + ## [0.10.1] - 2017/03/27 ### Changed diff --git a/kong-0.10.1-0.rockspec b/kong-0.10.1-0.rockspec index ce9218e0263a..13cf2bae882e 100644 --- a/kong-0.10.1-0.rockspec +++ b/kong-0.10.1-0.rockspec @@ -275,5 +275,8 @@ build = { ["kong.plugins.aws-lambda.handler"] = "kong/plugins/aws-lambda/handler.lua", ["kong.plugins.aws-lambda.schema"] = "kong/plugins/aws-lambda/schema.lua", ["kong.plugins.aws-lambda.v4"] = "kong/plugins/aws-lambda/v4.lua", + + ["kong.plugins.request-termination.handler"] = "kong/plugins/request-termination/handler.lua", + ["kong.plugins.request-termination.schema"] = "kong/plugins/request-termination/schema.lua", } } diff --git a/kong/constants.lua b/kong/constants.lua index ab05501b5389..3c7375452436 100644 --- a/kong/constants.lua +++ b/kong/constants.lua @@ -1,10 +1,32 @@ local plugins = { - "jwt", "acl", "correlation-id", "cors", "oauth2", "tcp-log", "udp-log", - "file-log", "http-log", "key-auth", "hmac-auth", "basic-auth", "ip-restriction", - "galileo", "request-transformer", "response-transformer", - "request-size-limiting", "rate-limiting", "response-ratelimiting", "syslog", - "loggly", "datadog", "runscope", "ldap-auth", "statsd", "bot-detection", - "aws-lambda" + "jwt", + "acl", + "correlation-id", + "cors", + "oauth2", + "tcp-log", + "udp-log", + "file-log", + "http-log", + "key-auth", + "hmac-auth", + "basic-auth", + "ip-restriction", + "galileo", + "request-transformer", + "response-transformer", + "request-size-limiting", + "rate-limiting", + "response-ratelimiting", + "syslog", + "loggly", + "datadog", + "runscope", + "ldap-auth", + "statsd", + "bot-detection", + "aws-lambda", + "request-termination", } local plugin_map = {} diff --git a/kong/plugins/request-termination/handler.lua b/kong/plugins/request-termination/handler.lua new file mode 100644 index 000000000000..31237b95c398 --- /dev/null +++ b/kong/plugins/request-termination/handler.lua @@ -0,0 +1,39 @@ +local BasePlugin = require "kong.plugins.base_plugin" +local responses = require "kong.tools.responses" +local meta = require "kong.meta" + +local server_header = meta._NAME.."/"..meta._VERSION + +local RequestTerminationHandler = BasePlugin:extend() + +RequestTerminationHandler.PRIORITY = 1 + +function RequestTerminationHandler:new() + RequestTerminationHandler.super.new(self, "request-termination") +end + +function RequestTerminationHandler:access(conf) + RequestTerminationHandler.super.access(self) + + local status_code = conf.status_code + local content_type = conf.content_type + local body = conf.body + local message = conf.message + if body then + ngx.status = status_code + + if not content_type then + content_type = "application/json; charset=utf-8"; + end + ngx.header["Content-Type"] = content_type + ngx.header["Server"] = server_header + + ngx.say(body) + + return ngx.exit(status_code) + else + return responses.send(status_code, message) + end +end + +return RequestTerminationHandler diff --git a/kong/plugins/request-termination/schema.lua b/kong/plugins/request-termination/schema.lua new file mode 100644 index 000000000000..3a08100d4d4a --- /dev/null +++ b/kong/plugins/request-termination/schema.lua @@ -0,0 +1,31 @@ +local Errors = require "kong.dao.errors" +local utils = require "kong.tools.utils" + +return { + no_consumer = true, + fields = { + status_code = { type = "number", default = 503 }, + message = { type = "string" }, + content_type = { type = "string" }, + body = { type = "string" }, + }, + self_check = function(schema, plugin_t, dao, is_updating) + if plugin_t.status_code then + if plugin_t.status_code < 100 or plugin_t.status_code > 599 then + return false, Errors.schema("status_code must be between 100..599") + end + end + + if plugin_t.message then + if plugin_t.content_type or plugin_t.body then + return false, Errors.schema("message cannot be used with content_type or body") + end + else + if plugin_t.content_type and not plugin_t.body then + return false, Errors.schema("content_type requires a body") + end + end + + return true + end +} diff --git a/kong/tools/responses.lua b/kong/tools/responses.lua index 25517586c83f..1cdee585d485 100644 --- a/kong/tools/responses.lua +++ b/kong/tools/responses.lua @@ -39,6 +39,7 @@ local server_header = meta._NAME.."/"..meta._VERSION -- @field HTTP_CONFLICT 409 Conflict -- @field HTTP_UNSUPPORTED_MEDIA_TYPE 415 Unsupported Media Type -- @field HTTP_INTERNAL_SERVER_ERROR Internal Server Error +-- @field HTTP_SERVICE_UNAVAILABLE 503 Service Unavailable -- @usage return responses.send_HTTP_OK() -- @usage return responses.HTTP_CREATED("Entity created") -- @usage return responses.HTTP_INTERNAL_SERVER_ERROR() @@ -55,7 +56,8 @@ local _M = { HTTP_METHOD_NOT_ALLOWED = 405, HTTP_CONFLICT = 409, HTTP_UNSUPPORTED_MEDIA_TYPE = 415, - HTTP_INTERNAL_SERVER_ERROR = 500 + HTTP_INTERNAL_SERVER_ERROR = 500, + HTTP_SERVICE_UNAVAILABLE = 503, } } @@ -68,6 +70,7 @@ local _M = { -- @field status_codes.HTTP_UNAUTHORIZED Default: Unauthorized -- @field status_codes.HTTP_INTERNAL_SERVER_ERROR Always "Internal Server Error" -- @field status_codes.HTTP_METHOD_NOT_ALLOWED Always "Method not allowed" +-- @field status_codes.HTTP_SERVICE_UNAVAILABLE Default: "Service unavailable" local response_default_content = { [_M.status_codes.HTTP_UNAUTHORIZED] = function(content) return content or "Unauthorized" @@ -83,7 +86,10 @@ local response_default_content = { end, [_M.status_codes.HTTP_METHOD_NOT_ALLOWED] = function(content) return "Method not allowed" - end + end, + [_M.status_codes.HTTP_SERVICE_UNAVAILABLE] = function(content) + return content or "Service unavailable" + end, } -- Return a closure which will be usable to respond with a certain status code. @@ -91,12 +97,12 @@ local response_default_content = { -- @param[type=number] status_code The status for which to define a function local function send_response(status_code) -- Send a JSON response for the closure's status code with the given content. - -- If the content happens to be an error (>500), it will be logged by ngx.log as an ERR. + -- If the content happens to be an error (500), it will be logged by ngx.log as an ERR. -- @see https://github.com/openresty/lua-nginx-module -- @param content (Optional) The content to send as a response. -- @return ngx.exit (Exit current context) return function(content, headers) - if status_code >= _M.status_codes.HTTP_INTERNAL_SERVER_ERROR then + if status_code == _M.status_codes.HTTP_INTERNAL_SERVER_ERROR then if content then ngx.log(ngx.ERR, tostring(content)) end @@ -141,7 +147,8 @@ local closure_cache = {} --- Send a response with any status code or body, -- Not all status codes are available as sugar methods, this function can be -- used to send any response. --- If the `status_code` parameter is in the 5xx range, it is expectde that the `content` parameter be the error encountered. It will be logged and the response body will be empty. The user will just receive a 500 status code. +-- For `status_code=5xx` the `content` parameter should be the description of the error that occurred. +-- For `status_code=500` the content will be logged by ngx.log as an ERR. -- Will call `ngx.say` and `ngx.exit`, terminating the current context. -- @see ngx.say -- @see ngx.exit diff --git a/spec/01-unit/09-responses_spec.lua b/spec/01-unit/09-responses_spec.lua index 77b0b89c3f87..1b7bad3fb60d 100644 --- a/spec/01-unit/09-responses_spec.lua +++ b/spec/01-unit/09-responses_spec.lua @@ -58,9 +58,12 @@ describe("Response helpers", function() end) end end) - it("calls `ngx.log` if and only if a 500 status code range was given", function() + it("calls `ngx.log` if and only if a 500 status code was given", function() responses.send_HTTP_BAD_REQUEST() assert.stub(ngx.log).was_not_called() + + responses.send_HTTP_BAD_REQUEST("error") + assert.stub(ngx.log).was_not_called() responses.send_HTTP_INTERNAL_SERVER_ERROR() assert.stub(ngx.log).was_not_called() @@ -69,6 +72,14 @@ describe("Response helpers", function() assert.stub(ngx.log).was_called() end) + it("don't call `ngx.log` if a 503 status code was given", function() + responses.send_HTTP_SERVICE_UNAVAILABLE() + assert.stub(ngx.log).was_not_called() + + responses.send_HTTP_SERVICE_UNAVAILABLE() + assert.stub(ngx.log).was_not_called("error") + end) + describe("default content rules for some status codes", function() it("should apply default content rules for some status codes", function() responses.send_HTTP_NOT_FOUND() @@ -86,6 +97,12 @@ describe("Response helpers", function() responses.send_HTTP_INTERNAL_SERVER_ERROR("override") assert.stub(ngx.say).was.called_with("{\"message\":\"An unexpected error occurred\"}") end) + it("should apply default content rules for some status codes", function() + responses.send_HTTP_SERVICE_UNAVAILABLE() + assert.stub(ngx.say).was.called_with("{\"message\":\"Service unavailable\"}") + responses.send_HTTP_SERVICE_UNAVAILABLE("override") + assert.stub(ngx.say).was.called_with("{\"message\":\"override\"}") + end) end) describe("send()", function() diff --git a/spec/03-plugins/27-request-termination/01-schema_spec.lua b/spec/03-plugins/27-request-termination/01-schema_spec.lua new file mode 100644 index 000000000000..9b342141542f --- /dev/null +++ b/spec/03-plugins/27-request-termination/01-schema_spec.lua @@ -0,0 +1,57 @@ +local schemas_validation = require "kong.dao.schemas_validation" +local schema = require "kong.plugins.request-termination.schema" + +local v = schemas_validation.validate_entity + +describe("Plugin: request-termination (schema)", function() + it("should accept a valid status_code", function() + assert(v({status_code = 404}, schema)) + end) + it("should accept a valid message", function() + assert(v({message = "Not found"}, schema)) + end) + it("should accept a valid content_type", function() + assert(v({content_type = "text/html",body = "

Not found

"}, schema)) + end) + it("should accept a valid body", function() + assert(v({body = "

Not found

"}, schema)) + end) + + describe("errors", function() + it("status_code should only accept numbers", function() + local ok, err = v({status_code = "abcd"}, schema) + assert.same({status_code = "status_code is not a number"}, err) + assert.False(ok) + end) + it("status_code < 100", function() + local ok, _, err = v({status_code = "99"}, schema) + assert.False(ok) + assert.same("status_code must be between 100..599", err.message) + end) + it("status_code > 599", function() + local ok, _, err = v({status_code = "600"}, schema) + assert.False(ok) + assert.same("status_code must be between 100..599", err.message) + end) + it("message with body", function() + local ok, _, err = v({message = "error", body = "test"}, schema) + assert.False(ok) + assert.same("message cannot be used with content_type or body", err.message) + end) + it("message with body and content_type", function() + local ok, _, err = v({message = "error", content_type="text/html", body = "test"}, schema) + assert.False(ok) + assert.same("message cannot be used with content_type or body", err.message) + end) + it("message with content_type", function() + local ok, _, err = v({message = "error", content_type="text/html"}, schema) + assert.False(ok) + assert.same("message cannot be used with content_type or body", err.message) + end) + it("content_type without body", function() + local ok, _, err = v({content_type="text/html"}, schema) + assert.False(ok) + assert.same("content_type requires a body", err.message) + end) + end) +end) diff --git a/spec/03-plugins/27-request-termination/02-access_spec.lua b/spec/03-plugins/27-request-termination/02-access_spec.lua new file mode 100644 index 000000000000..0ee832805135 --- /dev/null +++ b/spec/03-plugins/27-request-termination/02-access_spec.lua @@ -0,0 +1,177 @@ +local helpers = require "spec.helpers" +local cache = require "kong.tools.database_cache" +local cjson = require "cjson" + +describe("Plugin: request-termination (access)", function() + local plugin_config + local client, admin_client + + setup(function() + local api1 = assert(helpers.dao.apis:insert { + name = "api-1", + hosts = { "api1.request-termination.com" }, + upstream_url = "http://mockbin.com" + }) + assert(helpers.dao.plugins:insert { + name = "request-termination", + api_id = api1.id, + config = { + } + }) + local api2 = assert(helpers.dao.apis:insert { + name = "api-2", + hosts = { "api2.request-termination.com" }, + upstream_url = "http://mockbin.com" + }) + assert(helpers.dao.plugins:insert { + name = "request-termination", + api_id = api2.id, + config = { + status_code=404 + } + }) + local api3 = assert(helpers.dao.apis:insert { + name = "api-3", + hosts = { "api3.request-termination.com" }, + upstream_url = "http://mockbin.com" + }) + assert(helpers.dao.plugins:insert { + name = "request-termination", + api_id = api3.id, + config = { + status_code=406, + message="Invalid" + } + }) + local api4 = assert(helpers.dao.apis:insert { + name = "api-4", + hosts = { "api4.request-termination.com" }, + upstream_url = "http://mockbin.com" + }) + assert(helpers.dao.plugins:insert { + name = "request-termination", + api_id = api4.id, + config = { + body="

Service is down for maintenance

" + } + }) + local api5 = assert(helpers.dao.apis:insert { + name = "api-5", + hosts = { "api5.request-termination.com" }, + upstream_url = "http://mockbin.com" + }) + assert(helpers.dao.plugins:insert { + name = "request-termination", + api_id = api5.id, + config = { + status_code=451, + content_type="text/html", + body="

Service is down due to content infringement

" + } + }) + local api6 = assert(helpers.dao.apis:insert { + name = "api-6", + hosts = { "api6.request-termination.com" }, + upstream_url = "http://mockbin.com" + }) + assert(helpers.dao.plugins:insert { + name = "request-termination", + api_id = api6.id, + config = { + status_code=503, + body='{"code": 1, "message": "Service unavailable"}' + } + }) + + + assert(helpers.start_kong()) + client = helpers.proxy_client() + admin_client = helpers.admin_client() + end) + + teardown(function() + if client and admin_client then + client:close() + admin_client:close() + end + helpers.stop_kong() + end) + + describe("status code and message", function() + it("default status code and message", function() + local res = assert(client:send { + method = "GET", + path = "/status/200", + headers = { + ["Host"] = "api1.request-termination.com" + } + }) + local body = assert.res_status(503, res) + assert.equal([[{"message":"Service unavailable"}]], body) + end) + + it("status code with default message", function() + local res = assert(client:send { + method = "GET", + path = "/status/200", + headers = { + ["Host"] = "api2.request-termination.com" + } + }) + local body = assert.res_status(404, res) + assert.equal([[{"message":"Not found"}]], body) + end) + + it("status code with custom message", function() + local res = assert(client:send { + method = "GET", + path = "/status/200", + headers = { + ["Host"] = "api3.request-termination.com" + } + }) + local body = assert.res_status(406, res) + assert.equal([[{"message":"Invalid"}]], body) + end) + + end) + + describe("status code and body", function() + it("default status code and body", function() + local res = assert(client:send { + method = "GET", + path = "/status/200", + headers = { + ["Host"] = "api4.request-termination.com" + } + }) + local body = assert.res_status(503, res) + assert.equal([[

Service is down for maintenance

]], body) + end) + + it("status code with default message", function() + local res = assert(client:send { + method = "GET", + path = "/status/200", + headers = { + ["Host"] = "api5.request-termination.com" + } + }) + local body = assert.res_status(451, res) + assert.equal([[

Service is down due to content infringement

]], body) + end) + + it("status code with custom message", function() + local res = assert(client:send { + method = "GET", + path = "/status/200", + headers = { + ["Host"] = "api6.request-termination.com" + } + }) + local body = assert.res_status(503, res) + assert.equal([[{"code": 1, "message": "Service unavailable"}]], body) + end) + + end) +end)