diff --git a/apisix/plugins/error-page.lua b/apisix/plugins/error-page.lua new file mode 100644 index 000000000000..9a7968cdf7fe --- /dev/null +++ b/apisix/plugins/error-page.lua @@ -0,0 +1,144 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +local core = require("apisix.core") +local plugin = require("apisix.plugin") +local plugin_name = "error-page" +local ngx = ngx +local format = string.format + + +local function err_body(code) + local tpl = [[ +%s + +

%s

+
Apache APISIX
+ +]] + return format(tpl, code, code) +end + + +local metadata_schema = { + type = "object", + properties = { + enable = {type = "boolean", default = false}, + error_404 = { + type = "object", + properties = { + body = {type = "string", default = err_body("404 Not Found")}, + content_type = {type = "string", default = "text/html"}, + } + }, + error_500 = { + type = "object", + properties = { + body = {type = "string", default = err_body("500 Internal Server Error")}, + content_type = {type = "string", default = "text/html"}, + } + }, + error_502 = { + type = "object", + properties = { + body = {type = "string", default = err_body("502 Bad Gateway")}, + content_type = {type = "string", default = "text/html"}, + } + }, + error_503 = { + type = "object", + properties = { + body = {type = "string", default = err_body("503 Service Unavailable")}, + content_type = {type = "string", default = "text/html"}, + } + } + }, +} + +local schema = {} + +local _M = { + version = 0.1, + priority = 450, + name = plugin_name, + schema = schema, + metadata_schema = metadata_schema, +} + + +function _M.check_schema(conf, schema_type) + if schema_type == core.schema.TYPE_METADATA then + return core.schema.check(metadata_schema, conf) + end + return core.schema.check(schema, conf) +end + + +-- return metadata only if the response should be modified +local function get_metadata(ctx) + local status = ngx.status + if ctx.var.upstream_status then + return nil + end + + if status < 404 then + return nil + end + + local metadata = plugin.plugin_metadata(plugin_name) + if not metadata then + core.log.info("failed to read metadata for ", plugin_name) + return nil + end + core.log.debug(plugin_name, " metadata: ", core.json.delay_encode(metadata)) + metadata = metadata.value + if not metadata.enable then + return nil + end + + local err_page = metadata["error_" .. status] + if not err_page or not (err_page.body and #err_page.body > 0) then + core.log.debug("error page for error_", status, " not defined, default will be used.") + return nil + end + + return metadata +end + + +function _M.header_filter(conf, ctx) + ctx.plugin_error_page_meta = get_metadata(ctx) + if not ctx.plugin_error_page_meta then + return + end + local status = ngx.status + local err_page = ctx.plugin_error_page_meta["error_" .. status] + core.response.set_header("content-type", err_page.content_type) + core.response.set_header("content-length", #err_page.body) +end + + +function _M.body_filter(conf, ctx) + if not ctx.plugin_error_page_meta then + return + end + + ngx.arg[1] = ctx.plugin_error_page_meta["error_" .. ngx.status].body + ngx.arg[2] = true +end + + +return _M diff --git a/conf/config.yaml.example b/conf/config.yaml.example index 6023c83bc3dc..106a09de4350 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -547,6 +547,7 @@ plugins: # plugin list (sorted by priority) - public-api # priority: 501 - prometheus # priority: 500 - datadog # priority: 495 + #- error-page # priority: 450 - lago # priority: 415 - loki-logger # priority: 414 - elasticsearch-logger # priority: 413 diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json index 115448b95b0e..14ecd1ab70d1 100644 --- a/docs/en/latest/config.json +++ b/docs/en/latest/config.json @@ -106,6 +106,7 @@ "label": "Transformation", "items": [ "plugins/response-rewrite", + "plugins/error-page", "plugins/proxy-rewrite", "plugins/grpc-transcode", "plugins/grpc-web", diff --git a/docs/en/latest/plugins/error-page.md b/docs/en/latest/plugins/error-page.md new file mode 100644 index 000000000000..45786d78e6e8 --- /dev/null +++ b/docs/en/latest/plugins/error-page.md @@ -0,0 +1,171 @@ +--- +title: error-page +keywords: + - Apache APISIX + - API Gateway + - Plugin + - Error page + - error-page +description: The error-page Plugin customizes the HTTP error response body and content type for APISIX-generated error responses, such as when no route matches or when APISIX itself encounters an error. +--- + + + + + + + +## Description + +The `error-page` Plugin customizes the response body and content type for HTTP error responses generated by APISIX itself (for example, when no route matches or when the upstream is unreachable). Responses from upstream services are not affected. + +This Plugin uses [Plugin metadata](../terminology/plugin-metadata.md) for global configuration and requires no per-route attributes. When enabled, it intercepts error responses and replaces their body with the configured content. + +## Plugin Metadata + +There are no attributes to configure this Plugin on Routes, Services, or other resources. All configuration is done through Plugin metadata. + +| Name | Type | Required | Default | Description | +| ---- | ---- | -------- | ------- | ----------- | +| enable | boolean | False | false | Set to `true` to enable the Plugin. | +| error_404 | object | False | | Custom error page for 404 responses. | +| error_404.body | string | False | Default HTML page | Response body for 404 responses. | +| error_404.content_type | string | False | text/html | Content type of the response body. | +| error_500 | object | False | | Custom error page for 500 responses. | +| error_500.body | string | False | Default HTML page | Response body for 500 responses. | +| error_500.content_type | string | False | text/html | Content type of the response body. | +| error_502 | object | False | | Custom error page for 502 responses. | +| error_502.body | string | False | Default HTML page | Response body for 502 responses. | +| error_502.content_type | string | False | text/html | Content type of the response body. | +| error_503 | object | False | | Custom error page for 503 responses. | +| error_503.body | string | False | Default HTML page | Response body for 503 responses. | +| error_503.content_type | string | False | text/html | Content type of the response body. | + +## Enable Plugin + +The `error-page` Plugin is disabled by default. To enable the Plugin, add it to your configuration file (`conf/config.yaml`): + +```yaml title="conf/config.yaml" +plugins: + - ... + - error-page +``` + +## Configure Plugin Metadata + +:::note +You can fetch the `admin_key` from `config.yaml` and save to an environment variable with the following command: + +```bash +admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"//g') +``` + +::: + +Configure the Plugin metadata to enable the Plugin and define custom error pages: + +```shell +curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/error-page \ +-H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "enable": true, + "error_404": { + "body": "

404 - Page Not Found

", + "content_type": "text/html" + }, + "error_500": { + "body": "

500 - Internal Server Error

", + "content_type": "text/html" + }, + "error_502": { + "body": "

502 - Bad Gateway

", + "content_type": "text/html" + }, + "error_503": { + "body": "

503 - Service Unavailable

", + "content_type": "text/html" + } +}' +``` + +You can also return JSON error responses by setting a custom `content_type`: + +```shell +curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/error-page \ +-H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "enable": true, + "error_404": { + "body": "{\"code\": 404, \"message\": \"Resource not found\"}", + "content_type": "application/json" + }, + "error_500": { + "body": "{\"code\": 500, \"message\": \"Internal server error\"}", + "content_type": "application/json" + } +}' +``` + +Since the Plugin uses global metadata, you also need to enable it on the routes where you want it to take effect. You can use a [global rule](../terminology/global-rule.md) to apply it to all routes: + +```shell +curl http://127.0.0.1:9180/apisix/admin/global_rules/1 \ +-H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "plugins": { + "error-page": {} + } +}' +``` + +## Example usage + +After configuring the Plugin and metadata as shown above, trigger a 404 error by accessing a non-existent route: + +```shell +curl -i http://127.0.0.1:9080/non-existent-path +``` + +``` +HTTP/1.1 404 Not Found +Content-Type: text/html +... + +

404 - Page Not Found

+``` + +## Disable Plugin + +To stop the Plugin from intercepting errors, delete the Plugin metadata: + +```shell +curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/error-page \ +-H "X-API-KEY: $admin_key" -X DELETE +``` + +To remove the Plugin entirely from a route, delete it from the route's plugin configuration. APISIX will automatically reload and you do not have to restart for this to take effect. + +```shell +curl http://127.0.0.1:9180/apisix/admin/global_rules/1 \ +-H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "plugins": {} +}' +``` diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json index 6499144f74c1..9c93ea723d99 100644 --- a/docs/zh/latest/config.json +++ b/docs/zh/latest/config.json @@ -96,6 +96,7 @@ "label": "转换请求", "items": [ "plugins/response-rewrite", + "plugins/error-page", "plugins/proxy-rewrite", "plugins/grpc-transcode", "plugins/grpc-web", diff --git a/docs/zh/latest/plugins/error-page.md b/docs/zh/latest/plugins/error-page.md new file mode 100644 index 000000000000..3cef324824d0 --- /dev/null +++ b/docs/zh/latest/plugins/error-page.md @@ -0,0 +1,172 @@ +--- +title: error-page +keywords: + - Apache APISIX + - API 网关 + - Plugin + - Error page + - error-page +description: error-page 插件允许自定义 APISIX 生成的 HTTP 错误响应的响应体和内容类型,例如路由不匹配或上游不可达时返回的错误页面。 +--- + + + + + + + +## 描述 + +`error-page` 插件允许自定义 APISIX 本身生成的 HTTP 错误响应(例如,路由不匹配或上游不可达时)的响应体和内容类型。来自上游服务的响应不会受到影响。 + +该插件通过[插件元数据](../terminology/plugin-metadata.md)进行全局配置,无需在路由上配置属性。启用后,它会拦截错误响应并将响应体替换为配置的自定义内容。 + +## 插件元数据 + +该插件不支持在路由、服务或其他资源上配置属性,所有配置均通过插件元数据完成。 + +| 名称 | 类型 | 必选项 | 默认值 | 描述 | +| ---- | ---- | ------ | ------ | ---- | +| enable | boolean | 否 | false | 设为 `true` 以启用插件。 | +| error_404 | object | 否 | | 404 响应的自定义错误页面配置。 | +| error_404.body | string | 否 | 默认 HTML 页面 | 404 响应的响应体内容。 | +| error_404.content_type | string | 否 | text/html | 响应体的内容类型。 | +| error_500 | object | 否 | | 500 响应的自定义错误页面配置。 | +| error_500.body | string | 否 | 默认 HTML 页面 | 500 响应的响应体内容。 | +| error_500.content_type | string | 否 | text/html | 响应体的内容类型。 | +| error_502 | object | 否 | | 502 响应的自定义错误页面配置。 | +| error_502.body | string | 否 | 默认 HTML 页面 | 502 响应的响应体内容。 | +| error_502.content_type | string | 否 | text/html | 响应体的内容类型。 | +| error_503 | object | 否 | | 503 响应的自定义错误页面配置。 | +| error_503.body | string | 否 | 默认 HTML 页面 | 503 响应的响应体内容。 | +| error_503.content_type | string | 否 | text/html | 响应体的内容类型。 | + +## 启用插件 + +`error-page` 插件默认禁用。要启用该插件,请将其添加到配置文件(`conf/config.yaml`)中: + +```yaml title="conf/config.yaml" +plugins: + - ... + - error-page +``` + +## 配置插件元数据 + +:::note + +你可以通过以下命令从 `config.yaml` 中获取 `admin_key` 并保存为环境变量: + +```bash +admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"//g') +``` + +::: + +配置插件元数据,启用插件并定义自定义错误页面: + +```shell +curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/error-page \ +-H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "enable": true, + "error_404": { + "body": "

404 - 页面未找到

", + "content_type": "text/html" + }, + "error_500": { + "body": "

500 - 服务器内部错误

", + "content_type": "text/html" + }, + "error_502": { + "body": "

502 - 网关错误

", + "content_type": "text/html" + }, + "error_503": { + "body": "

503 - 服务不可用

", + "content_type": "text/html" + } +}' +``` + +你也可以通过设置自定义 `content_type` 来返回 JSON 格式的错误响应: + +```shell +curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/error-page \ +-H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "enable": true, + "error_404": { + "body": "{\"code\": 404, \"message\": \"资源未找到\"}", + "content_type": "application/json" + }, + "error_500": { + "body": "{\"code\": 500, \"message\": \"服务器内部错误\"}", + "content_type": "application/json" + } +}' +``` + +由于该插件使用全局元数据,你还需要在路由上启用该插件。可以使用[全局规则](../terminology/global-rule.md)将其应用于所有路由: + +```shell +curl http://127.0.0.1:9180/apisix/admin/global_rules/1 \ +-H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "plugins": { + "error-page": {} + } +}' +``` + +## 示例 + +按照上述方式配置插件和元数据后,访问一个不存在的路由触发 404 错误: + +```shell +curl -i http://127.0.0.1:9080/non-existent-path +``` + +``` +HTTP/1.1 404 Not Found +Content-Type: text/html +... + +

404 - 页面未找到

+``` + +## 禁用插件 + +若要停止插件拦截错误响应,删除插件元数据: + +```shell +curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/error-page \ +-H "X-API-KEY: $admin_key" -X DELETE +``` + +若要从路由中完全移除该插件,删除路由插件配置中对应的 JSON 配置。APISIX 将自动重新加载,无需重启。 + +```shell +curl http://127.0.0.1:9180/apisix/admin/global_rules/1 \ +-H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "plugins": {} +}' +``` diff --git a/t/plugin/error-page.t b/t/plugin/error-page.t new file mode 100644 index 000000000000..8cb3de228ddd --- /dev/null +++ b/t/plugin/error-page.t @@ -0,0 +1,378 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_root_location(); +no_shuffle(); +log_level('info'); + +add_block_preprocessor(sub { + my ($block) = @_; + + my $user_yaml_config = <<_EOC_; +plugins: + - error-page + - serverless-post-function +_EOC_ + $block->set_value("extra_yaml_config", $user_yaml_config); + + if (!defined $block->request) { + $block->set_value("request", "GET /t"); + } + + if (!defined $block->http_config) { + $block->set_value("http_config", <<_EOC_); + server { + listen 1987; + location / { + return 500 "real upstream 500 error"; + } + } +_EOC_ + } + + $block; +}); + +run_tests; + +__DATA__ + +=== TEST 1: set global rule to enable plugin +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/global_rules/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "error-page": {} + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 2: set route with serverless-post-function plugin to inject error status +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "serverless-post-function": { + "functions" : ["return function() if ngx.var.http_x_test_status ~= nil then;ngx.exit(tonumber(ngx.var.http_x_test_status));end;end"] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 3: without plugin metadata, error response should not be modified +--- request +GET /hello +--- more_headers +X-Test-Status: 502 +--- error_code: 502 +--- response_headers +content-type: text/html +--- response_body_like +.*openresty.* +--- error_log +failed to read metadata for error-page + + + +=== TEST 4: set plugin metadata with custom error pages +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/plugin_metadata/error-page', + ngx.HTTP_PUT, + [[{ + "enable": true, + "error_500": {"body": "

500 Internal Server Error

"}, + "error_404": {"body": "

404 Not Found

"}, + "error_502": {"body": "

502 Bad Gateway

"}, + "error_503": {"body": "

503 Service Unavailable

"} + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 5: custom error page for 500 +--- request +GET /hello +--- more_headers +X-Test-Status: 500 +--- error_code: 500 +--- response_headers +content-type: text/html +--- response_body_like eval +qr/

500 Internal Server Error<\/h1><\/body>/ + + + +=== TEST 6: custom error page for 502 +--- request +GET /hello +--- more_headers +X-Test-Status: 502 +--- error_code: 502 +--- response_headers +content-type: text/html +--- response_body_like eval +qr/

502 Bad Gateway<\/h1><\/body>/ + + + +=== TEST 7: custom error page for 503 +--- request +GET /hello +--- more_headers +X-Test-Status: 503 +--- error_code: 503 +--- response_headers +content-type: text/html +--- response_body_like eval +qr/

503 Service Unavailable<\/h1><\/body>/ + + + +=== TEST 8: custom error page for 404 +--- request +GET /hello +--- more_headers +X-Test-Status: 404 +--- error_code: 404 +--- response_headers +content-type: text/html +--- response_body_like eval +qr/

404 Not Found<\/h1><\/body>/ + + + +=== TEST 9: error page not configured for status 405 +--- request +GET /hello +--- more_headers +X-Test-Status: 405 +--- error_code: 405 + + + +=== TEST 10: set metadata with empty body for a status code +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/plugin_metadata/error-page', + ngx.HTTP_PUT, + [[{ + "error_405": {"content_type": "text/html"} + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 11: error page body not set falls back to default +--- request +GET /hello +--- more_headers +X-Test-Status: 405 +--- error_code: 405 + + + +=== TEST 12: delete plugin metadata +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/plugin_metadata/error-page', + ngx.HTTP_DELETE + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 13: after metadata deleted, error response not modified +--- request +GET /hello +--- more_headers +X-Test-Status: 500 +--- error_code: 500 +--- response_headers +content-type: text/html +--- response_body_like +.*openresty.* +--- error_log +failed to read metadata for error-page + + + +=== TEST 14: set metadata with custom content-type +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/plugin_metadata/error-page', + ngx.HTTP_PUT, + [[{ + "enable": true, + "error_500": { + "body": "{\"code\": 500, \"message\": \"Internal Server Error\"}", + "content_type": "application/json" + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 15: custom content-type is returned in response +--- request +GET /hello +--- more_headers +X-Test-Status: 500 +--- error_code: 500 +--- response_headers +content-type: application/json + + + +=== TEST 16: upstream errors are not intercepted +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/plugin_metadata/error-page', + ngx.HTTP_PUT, + [[{ + "enable": true, + "error_500": {"body": "

500 custom

"} + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 17: create a route pointing to a real upstream returning 500 +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/2', + ngx.HTTP_PUT, + [[{ + "uri": "/upstream-test", + "upstream": { + "nodes": {"127.0.0.1:1987": 1}, + "type": "roundrobin" + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 18: upstream 500 responses are not intercepted by error-page plugin +--- request +GET /upstream-test +--- error_code: 500 +--- response_body_like eval +qr/real upstream 500 error/