nginxluaopenrestyapache-apisix

Is it possible to override APISIX default response using a custom plugin?


Apache APISIX by default gives inconsistent responses when returning errors. Here are some examples:

As widely discussed in GitHub issues, this can be configured via a custom nginx configuration. Below are some references:

Is it possible to override this default behaviour using a custom plugin? How?

As an example, here's APISIX in Standalone Mode, running on Docker:

As we can notice, the responses are not normalized:


Solution

  • TL;DR

    Yes, it's possible to override APISIX default responses via a custom plugin.

    Solution Using a Custom Plugin custom-response

    File custom-response.lua:

    local plugin_name     = "custom-response"
    
    local ngx             = ngx
    local core            = require("apisix.core")
    local apisix_plugin   = require("apisix.plugin")
    local apisix_utils    = require("apisix.core.utils")
    
    local response_schema = {
      description = "Custom response to return when APISIX responds a specific status codes.",
      type = "object",
      additionalProperties = false,
      required = { "body" },
      properties = {
        body = {
          description = "Response body.",
          type = "string"
        },
        ["content-type"] = {
          description = "Response content type.",
          type = "string",
        },
      },
    }
    
    local metadata_schema = {
      type = "object",
      additionalProperties = false,
      patternProperties = {
        ["^error_[2-5]{1}[0-9]{2}$"] = response_schema,
      },
      properties = {
        id = plugin_name,
        enable = {
          description = "Wheter the plugin is enabled or not.",
          type = "boolean",
          default = true,
        },
        set_content_length = {
          description = "If true automatically set the Content-Length header. If set to false, removes the header.",
          type = "boolean",
          default = true,
        },
      },
    }
    
    local schema          = {
      type = "object",
      properties = {},
    }
    
    local _M              = {
      version = 1.0,
      priority = 0,
      name = plugin_name,
      schema = schema,
      metadata_schema = metadata_schema,
    }
    
    -- Reference: https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
    local status_texts    = {
      ["100"] = "Continue",
      ["101"] = "Switching Protocols",
      ["102"] = "Processing",
      ["103"] = "Early Hints",
    
      ["200"] = "OK",
      ["201"] = "Created",
      ["202"] = "Accepted",
      ["203"] = "Non-Authoritative Information",
      ["204"] = "No Content",
      ["205"] = "Reset Content",
      ["206"] = "Partial Content",
      ["207"] = "Multi-Status",
      ["208"] = "Already Reported",
      ["226"] = "IM Used",
    
      ["300"] = "Multiple Choices",
      ["301"] = "Moved Permanently",
      ["302"] = "Found",
      ["303"] = "See Other",
      ["304"] = "Not Modified",
      ["305"] = "Use Proxy",
      ["307"] = "Temporary Redirect",
      ["308"] = "Permanent Redirect",
    
      ["400"] = "Bad Request",
      ["401"] = "Unauthorized",
      ["402"] = "Payment Required",
      ["403"] = "Forbidden",
      ["404"] = "Not Found",
      ["405"] = "Method Not Allowed",
      ["406"] = "Not Acceptable",
      ["407"] = "Proxy Authentication Required",
      ["408"] = "Request Timeout",
      ["409"] = "Conflict",
      ["410"] = "Gone",
      ["411"] = "Length Required",
      ["412"] = "Precondition Failed",
      ["413"] = "Content Too Large",
      ["414"] = "URI Too Long",
      ["415"] = "Unsupported Media Type",
      ["416"] = "Range Not Satisfiable",
      ["417"] = "Expectation Failed",
      ["418"] = "I'm a teapot",
      ["421"] = "Misdirected Request",
      ["422"] = "Unprocessable Content",
      ["423"] = "Locked",
      ["424"] = "Failed Dependency",
      ["425"] = "Too Early",
      ["426"] = "Upgrade Required",
      ["428"] = "Precondition Required",
      ["429"] = "Too Many Requests",
      ["431"] = "Request Header Fields Too Large",
      ["451"] = "Unavailable For Legal Reasons",
    
      ["500"] = "Internal Server Error",
      ["501"] = "Not Implemented",
      ["502"] = "Bad Gateway",
      ["503"] = "Service Unavailable",
      ["504"] = "Gateway Timeout",
      ["505"] = "HTTP Version Not Supported",
      ["506"] = "Variant Also Negotiates",
      ["507"] = "Insufficient Storage",
      ["508"] = "Loop Detected",
      ["510"] = "Not Extended",
      ["511"] = "Network Authentication Required",
    
      default = "Something is wrong"
    }
    
    -- Reference: https://apisix.apache.org/docs/apisix/plugin-develop/#register-custom-variable
    core.ctx.register_var("status_text", function(ctx)
      return status_texts[ngx.var.status] or status_texts.default
    end)
    
    local function make_response(error)
      local response = {}
      response.body = error.body
      response.headers = { ["Content-Type"] = error["content-type"] }
      return response
    end
    
    function _M.check_schema(conf, schema_type)
      if schema_type == core.schema.TYPE_METADATA then
        return core.schema.check(metadata_schema, conf)
      end
    
      return true
    end
    
    function _M.header_filter(_, ctx)
      local custom_response
      local metadata = apisix_plugin.plugin_metadata(plugin_name)
      if not metadata or not metadata.value.enable then
        return
      end
    
      -- Return custom error page only if upstream didn't respond
      if ngx.var.upstream_status then
        return
      end
    
      for key, value in pairs(metadata.value) do
        if not string.match(key, '^error_') then
          goto continue
        end
    
    
        local error_code = string.gsub(key, "error_", "")
        if ngx.status ~= tonumber(error_code) then
          goto continue
        end
    
        if value.body then
          custom_response = make_response(value)
          break
        end
    
        ::continue::
      end
    
      -- This means a condition was triggered and we set a custom page
      if custom_response then
        -- header manipulation must be performed in header_filter phase
        if custom_response.headers then
          for key, value in pairs(custom_response.headers) do
            ngx.header[key] = value
          end
        end
    
        -- Parse NGiNX variables
        custom_response.body = apisix_utils.resolve_var(custom_response.body, ctx.var)
    
        -- Set Content-Length header before body_phase
        ngx.header['Content-Length'] = #(custom_response.body)
        if not metadata.value.set_content_length then
          ngx.header['Content-Length'] = nil
        end
    
        ctx.custom_response_body = custom_response.body
      end
    end
    
    function _M.body_filter(_, ctx)
      if ctx.custom_response_body then
        local body = core.response.hold_body_chunk(ctx)
    
        -- Don't send a response until we've read all chunks
        if ngx.arg[2] == false and not body then
          return
        end
    
        -- Last chunk was read, so we can return the response
        ngx.arg[1] = ctx.custom_response_body
        ctx.custom_response_body = nil
      end
    end
    
    return _M
    
    

    Example

    Configuration

    File structure:

    .
    ├── conf/
    │   ├── apisix.yaml
    │   └── config.yaml
    ├── apisix/
    │   └── plugins/
    │       └── custom-response.lua
    └── compose.yaml
    

    File compose.yaml:

    name: apisix-standalone
    
    services:
      apisix:
        image: apache/apisix:3.13.0-ubuntu
        stdin_open: true
        tty: true
        volumes:
          - ./conf/config.yaml:/usr/local/apisix/conf/config.yaml
          - ./conf/apisix.yaml:/usr/local/apisix/conf/apisix.yaml
          - ./apisix/plugins/:/usr/local/apisix/apisix/plugins/custom/apisix/plugins
        ports:
          - "9080:9080/tcp"
          - "9443:9443/tcp"
        networks:
          apisix:
    
      httpbin:
        image: kennethreitz/httpbin:latest
        ports:
          - "3000:80/tcp"
        networks:
          apisix:
    
    networks:
      apisix:
        driver: bridge
    

    File config.yaml:

    apisix:
      extra_lua_path: "/usr/local/apisix/apisix/plugins/custom/?.lua"
    
    deployment:
      role: data_plane
      role_data_plane:
        config_provider: yaml
    
    plugins:
      - custom-response                # priority: 0
      - serverless-post-function       # priority: -2000
    
    nginx_config:
      http_server_configuration_snippet: |
        set $custom_var "Hello World!";
    

    File apisix.yaml:

    upstreams:
      - id: httpbin_internal
        nodes:
          "httpbin:80": 1
        type: roundrobin
    
    global_rules:
      - id: custom_response
        plugins:
          custom-response: {}
    
    routes:
      - id: base_internal
        uri: /anything
        upstream_id: httpbin_internal
    
      - id: apisix_status
        uri: /apisix_status/*
        upstream_id: httpbin_internal
        plugins:
          serverless-post-function:
            phase: access
            functions:
              - |
                return function(conf, ctx)
                  local core = require("apisix.core")
                  local status_code = 200
    
                  local matched = ngx.re.match(ngx.var.uri, "^/apisix_status/([2-5][0-9]{2})$")
                  if matched then
                    status_code = tonumber(matched[1])
                  end
    
                  core.response.exit(status_code)
                end
    
    plugin_metadata:
      - id: custom-response
        enable: true
    
        # Override default 404 response
        error_404:
          body: |
            {
              "status_code": $status,
              "error_msg": "$status_text"
            }
          content-type: "application/json"
    
        # Use a custom variable, set in config.yaml
        error_500:
          body: |
            {
              "status_code": $status,
              "error_msg": "$status_text",
              "msg": "$custom_var"
            }
          content-type: "application/json"
    
        # Remove "Powered by APISIX"
        error_502:
          body: |
            <html>
            <head><title>$status $status_text</title></head>
            <body>
            <center><h1>$status $status_text</h1></center>
            <hr><center>openresty</center>
            </html>
          content-type: "text/html; charset=utf-8"
    #END
    

    Test Custom Responses

    Run services:

    docker compose up
    

    Test APISIX default responses for some status codes: