diff --git a/apisix/plugins/proxy-chain.lua b/apisix/plugins/proxy-chain.lua new file mode 100644 index 000000000000..5c9628bf0454 --- /dev/null +++ b/apisix/plugins/proxy-chain.lua @@ -0,0 +1,479 @@ +#!/usr/bin/env perl +# +# 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); +log_level('info'); +worker_connections(256); +no_root_location(); +no_shuffle(); + +run_tests(); + +__DATA__ + +=== TEST 1: add plugin +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + -- Create route with proxy-chain plugin configuration + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "proxy-chain": { + "services": [ + { + "uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/test", + "method": "POST" + } + ], + "token_header": "X-API-Key" + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:]] .. ngx.var.server_port .. [[": 1 + } + }, + "uri": "/proxy-chain" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 2: create mock service endpoint +--- config + location /test { + content_by_lua_block { + -- Read the incoming request body + ngx.req.read_body() + local body = ngx.req.get_body_data() + local cjson = require("cjson") + + -- Parse JSON body if available + local data = {} + if body and body ~= "" then + local success, decoded = pcall(cjson.decode, body) + if success then + data = decoded + end + end + + -- Add mock service response data to merge with original request + data.service_response = "test_value" + data.service_id = "service_1" + data.processed_by = "mock_service" + + -- Return JSON response + ngx.header['Content-Type'] = 'application/json' + ngx.say(cjson.encode(data)) + } + } +--- request +POST /test +{"original": "data"} +--- response_body_like +.*service_response.* + + + +=== TEST 3: create upstream endpoint +--- config + location /upstream { + content_by_lua_block { + -- This endpoint simulates the final upstream service + ngx.req.read_body() + local body = ngx.req.get_body_data() + + -- Return the received body to verify proxy-chain worked + ngx.header['Content-Type'] = 'application/json' + ngx.say('{"upstream_response": "received", "body": "' .. (body or "nil") .. '"}') + } + } +--- request +POST /upstream +--- response_body_like +.*upstream_response.* + + + +=== TEST 4: test proxy-chain plugin - successful chaining +--- config + location /t { + content_by_lua_block { + -- First create the route with proxy-chain plugin + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "proxy-chain": { + "services": [ + { + "uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/test", + "method": "POST" + } + ], + "token_header": "X-API-Key" + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:]] .. ngx.var.server_port .. [[": 1 + } + }, + "uri": "/proxy-chain" + }]] + ) + + if code >= 300 then + ngx.status = code + ngx.say("Failed to create route: " .. body) + return + end + + -- Wait a moment for the route to be ready + ngx.sleep(0.1) + + -- Now test the actual proxy-chain functionality + local http = require("resty.http") + local httpc = http.new() + + -- Make request to proxy-chain endpoint + local res, err = httpc:request_uri("http://127.0.0.1:" .. ngx.var.server_port .. "/proxy-chain", { + method = "POST", + body = '{"original_data": "test"}', + headers = { + ["Content-Type"] = "application/json", + ["X-API-Key"] = "test-token" -- Test token header + } + }) + + if not res then + ngx.status = 500 + ngx.say("Request failed: " .. (err or "unknown error")) + return + end + + ngx.status = res.status + ngx.say(res.body) + } + } +--- request +GET /t +--- response_body_like +.*service_response.* +--- no_error_log +[error] + + + +=== TEST 5: add route for multiple services test +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + -- Create route that chains multiple services + local code, body = t('/apisix/admin/routes/2', + ngx.HTTP_PUT, + [[{ + "plugins": { + "proxy-chain": { + "services": [ + { + "uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/test", + "method": "POST" + }, + { + "uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/test2", + "method": "POST" + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:]] .. ngx.var.server_port .. [[": 1 + } + }, + "uri": "/proxy-chain-multi" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 6: create second mock service +--- config + location /test2 { + content_by_lua_block { + -- Second service in the chain - receives merged data from first service + ngx.req.read_body() + local body = ngx.req.get_body_data() + local cjson = require("cjson") + + -- Parse the merged data from previous service + local data = {} + if body and body ~= "" then + local success, decoded = pcall(cjson.decode, body) + if success then + data = decoded + end + end + + -- Add additional data from second service + data.second_service = "second_value" + data.chain_step = 2 + data.final_processed = true + + -- Return merged response + ngx.header['Content-Type'] = 'application/json' + ngx.say(cjson.encode(data)) + } + } +--- request +POST /test2 +--- response_body_like +.*second_service.* + + + +=== TEST 7: test multiple service chaining +--- config + location /t { + content_by_lua_block { + -- Wait for route to be ready + ngx.sleep(0.1) + + local http = require("resty.http") + local httpc = http.new() + + -- Test chaining multiple services + local res, err = httpc:request_uri("http://127.0.0.1:" .. ngx.var.server_port .. "/proxy-chain-multi", { + method = "POST", + body = '{"initial": "data"}', + headers = { + ["Content-Type"] = "application/json" + } + }) + + if not res then + ngx.status = 500 + ngx.say("Request failed: " .. (err or "unknown error")) + return + end + + ngx.status = res.status + ngx.say(res.body) + } + } +--- request +GET /t +--- response_body_like +.*second_service.* +--- no_error_log +[error] + + + +=== TEST 8: test invalid configuration - empty services array +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + -- Test invalid configuration with empty services array + local code, body = t('/apisix/admin/routes/3', + ngx.HTTP_PUT, + [[{ + "plugins": { + "proxy-chain": { + "services": [] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + }, + "uri": "/invalid" + }]] + ) + + ngx.status = code + ngx.say(body) + } + } +--- request +GET /t +--- error_code: 400 + + + +=== TEST 9: test service error handling +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + -- Create route with service that will fail (nonexistent endpoint) + local code, body = t('/apisix/admin/routes/4', + ngx.HTTP_PUT, + [[{ + "plugins": { + "proxy-chain": { + "services": [ + { + "uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/nonexistent", + "method": "POST" + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:]] .. ngx.var.server_port .. [[": 1 + } + }, + "uri": "/error-test" + }]] + ) + + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + -- Wait for route to be ready + ngx.sleep(0.1) + + local http = require("resty.http") + local httpc = http.new() + + -- Test error handling when service fails + local res, err = httpc:request_uri("http://127.0.0.1:" .. ngx.var.server_port .. "/error-test", { + method = "POST", + body = '{"test": "data"}', + headers = { + ["Content-Type"] = "application/json" + } + }) + + ngx.status = res and res.status or 500 + ngx.say(res and res.body or "Request failed") + } + } +--- request +GET /t +--- error_code: 404 + + + +=== TEST 10: test without token header configuration +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + -- Test proxy-chain without token_header configuration + local code, body = t('/apisix/admin/routes/5', + ngx.HTTP_PUT, + [[{ + "plugins": { + "proxy-chain": { + "services": [ + { + "uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/test", + "method": "POST" + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:]] .. ngx.var.server_port .. [[": 1 + } + }, + "uri": "/no-token-test" + }]] + ) + + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + ngx.sleep(0.1) + + local http = require("resty.http") + local httpc = http.new() + + -- Test without any token headers + local res, err = httpc:request_uri("http://127.0.0.1:" .. ngx.var.server_port .. "/no-token-test", { + method = "POST", + body = '{"no_token": "test"}', + headers = { + ["Content-Type"] = "application/json" + } + }) + + if not res then + ngx.status = 500 + ngx.say("Request failed: " .. (err or "unknown error")) + return + end + + ngx.status = res.status + ngx.say(res.body) + } + } +--- request +GET /t +--- response_body_like +.*service_response.* +--- no_error_log +[error] diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json index bdbdf778596d..6904155cadbc 100644 --- a/docs/en/latest/config.json +++ b/docs/en/latest/config.json @@ -351,6 +351,10 @@ "type": "doc", "id": "stream-proxy" }, + { + "type": "doc", + "id": "proxy-chain" + }, { "type": "doc", "id": "grpc-proxy" diff --git a/docs/en/latest/proxy-chain.md b/docs/en/latest/proxy-chain.md new file mode 100644 index 000000000000..a718a93ec8bd --- /dev/null +++ b/docs/en/latest/proxy-chain.md @@ -0,0 +1,191 @@ +--- +title: Proxy Chain Plugin for APISIX +--- + + + +[proxy-chain](https://github.com/apache/apisix) is a plugin for [APISIX](https://github.com/apache/apisix) that allows you to chain multiple upstream service calls in sequence, passing data between them as needed. This is particularly useful for workflows where a request must interact with several services before returning a final response to the client. + +## Description + +proxy-chain is a custom plugin for Apache APISIX that enables chaining multiple upstream service calls in a specific sequence. This is useful when a single client request needs to interact with multiple services before generating the final response. +This plugin allows APISIX to execute multiple HTTP calls to different upstream services, one after another. The output of one call can be used by the next, enabling complex workflows (e.g., collecting user info, validating payments, updating inventory). + +### Typical use cases: + +- Multi-step workflows like checkout flows +- Aggregating data from multiple internal services +- Orchestrating legacy APIs + +## Features + +- Chain multiple upstream service calls in a defined order. +- Pass custom headers (e.g., authentication tokens) between services. +- Flexible configuration for service endpoints and HTTP methods. + +## Attributes + +| Name | Type | Required | Default | Description | +|----------------|--------|----------|---------|--------------------------------------------------| +| services | array | Yes | - | List of upstream services to chain. | +| services.uri | string | Yes | - | URI of the upstream service. | +| services.method| string | Yes | - | HTTP method (e.g., "GET", "POST"). | +| token_header | string | No | - | Custom header to pass a token between services. | + +## Enable Plugin + +Use the Admin API to bind the plugin to a route: + +### Docker + +#### Configuration Steps + +1. **Add to Route**: + - Use the APISIX Admin API to configure a route: + + ```bash + curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/24 \ + -H "X-API-KEY: edd1c9f034335f136f87ad84b625c8f1" \ + -H 'Content-Type: application/json' \ + -d '{ + "uri": "/api/v1/checkout", + "methods": ["POST"], + "plugins": { + "proxy-chain": { + "services": [ + { + "uri": "http://customer_service/api/v1/user", + "method": "POST" + } + ] + } + }, + "upstream_id": "550932803756229477" + }' + ``` + +2. **Verify**: + - Test the endpoint: + + ```bash + curl -X POST http:///v1/checkout + ``` + +### Kubernetes + +#### Configuration Steps + +1. **Add to Route**: + - Assuming APISIX Ingress Controller is installed, use a custom resource (CRD) or Admin API: + + ```yaml + apiVersion: apisix.apache.org/v2 + kind: ApisixRoute + metadata: + name: checkout-route + spec: + http: + - name: checkout + match: + paths: + - /v1/checkout + methods: + - POST + backends: + - serviceName: upstream-service + servicePort: 80 + plugins: + - name: proxy-chain + enable: true + config: + services: + - uri: "http://customer_service/api/v1/user" + method: "POST" + ``` + + - Apply the CRD: + + ```bash + kubectl apply -f route.yaml + ``` + + - Alternatively, use the Admin API via port-forwarding: + + ```bash + kubectl port-forward service/apisix-service 9180:9180 + curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/24 \ + -H "X-API-KEY: edd1c9f034335f136f87ad84b625c8f1" \ + -H 'Content-Type: application/json' \ + -d '{ + "uri": "/offl/v1/checkout", + "methods": ["POST"], + "plugins": { + "proxy-chain": { + "services": [ + { + "uri": "http://customer_service/api/v1/user", + "method": "POST" + } + ], + } + }, + "upstream_id": "550932803756229477" + }' + ``` + +2. **Verify**: + - Test the endpoint (assuming a LoadBalancer or Ingress): + + ```bash + curl -X POST http:///v1/checkout + ``` + +## Example usage + +Once the plugin is enabled on a route, you can send a request like this: + +```bash +curl -X POST http://127.0.0.1:9080/v1/checkout \ + -H "Content-Type: application/json" \ + -d '{"cart_id": "abc123"}' +``` + +This will trigger the sequence of service calls defined in services. + +## Delete Plugin + +To disable the plugin for a route, remove it from the plugin list via Admin API: + +```bash +curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/checkout \ + -H "X-API-KEY: edd1c9f034335f136f87ad84b625c8f1" \ + -H 'Content-Type: application/json' \ + -d '{ + "uri": "/v1/checkout", + "methods": ["POST"], + "plugins": {}, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }' +``` diff --git a/t/plugin/proxy-chain.t b/t/plugin/proxy-chain.t new file mode 100644 index 000000000000..cef79e8d4c0b --- /dev/null +++ b/t/plugin/proxy-chain.t @@ -0,0 +1,236 @@ +#!/usr/bin/env perl +# +# 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); +log_level('info'); +worker_connections(256); +no_root_location(); +no_shuffle(); + +run_tests(); + +__DATA__ + +=== TEST 1: check plugin schema +--- config + location /t { + content_by_lua_block { + -- Test basic schema validation for proxy-chain plugin + local plugin = require("apisix.plugins.proxy-chain") + local ok, err = plugin.check_schema({ + services = { + { + uri = "http://127.0.0.1:1999/test", + method = "POST" + } + } + }) + if not ok then + ngx.say("failed: ", err) + return + end + ngx.say("passed") + } + } +--- request +GET /t +--- response_body +passed + +=== TEST 2: check plugin schema (invalid) +--- config + location /t { + content_by_lua_block { + -- Test schema validation with invalid configuration (empty services array) + local plugin = require("apisix.plugins.proxy-chain") + local ok, err = plugin.check_schema({ + services = {} + }) + if not ok then + ngx.say("failed as expected: ", err) + return + end + ngx.say("should have failed") + } + } +--- request +GET /t +--- response_body_like +failed as expected.* + +=== TEST 3: set route with proxy-chain plugin +--- config + location /t { + content_by_lua_block { + -- Create a route with proxy-chain plugin configuration + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "proxy-chain": { + "services": [ + { + "uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/mock-service", + "method": "POST" + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:]] .. ngx.var.server_port .. [[": 1 + } + }, + "uri": "/test-proxy-chain" + }]] + ) + + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + ngx.say("passed") + } + } +--- request +GET /t +--- response_body +passed + +=== TEST 4: create mock service +--- config + location /mock-service { + content_by_lua_block { + -- Mock service that receives request and adds additional data + ngx.req.read_body() + local body = ngx.req.get_body_data() + local cjson = require("cjson") + + -- Parse incoming JSON data + local data = {} + if body and body ~= "" then + local success, decoded = pcall(cjson.decode, body) + if success then + data = decoded + end + end + + -- Add mock response data to be merged + data.mock_response = "added by mock service" + data.processed = true + + -- Return merged JSON response + ngx.header['Content-Type'] = 'application/json' + ngx.say(cjson.encode(data)) + } + } +--- request +POST /mock-service +{"test": "data"} +--- response_body_like +.*mock_response.* + +=== TEST 5: create final upstream +--- config + location /final-upstream { + content_by_lua_block { + -- Final upstream service that receives the merged data from proxy-chain + ngx.req.read_body() + local body = ngx.req.get_body_data() + + -- Return the received body to verify proxy-chain worked correctly + ngx.header['Content-Type'] = 'application/json' + ngx.say('{"final_response": "success", "received_body": "' .. (body or "empty") .. '"}') + } + } +--- request +POST /final-upstream +--- response_body_like +.*final_response.* + +=== TEST 6: test proxy-chain functionality +--- config + location /t { + content_by_lua_block { + -- Test the complete proxy-chain functionality + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/2', + ngx.HTTP_PUT, + [[{ + "plugins": { + "proxy-chain": { + "services": [ + { + "uri": "http://127.0.0.1:]] .. ngx.var.server_port .. [[/mock-service", + "method": "POST" + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:]] .. ngx.var.server_port .. [[": 1 + } + }, + "uri": "/final-upstream" + }]] + ) + + if code >= 300 then + ngx.status = code + ngx.say("Route creation failed: " .. body) + return + end + + -- Wait for route to be ready + ngx.sleep(0.5) + + -- Make request to test proxy-chain functionality + local http = require("resty.http") + local httpc = http.new() + + local res, err = httpc:request_uri("http://127.0.0.1:" .. ngx.var.server_port .. "/final-upstream", { + method = "POST", + body = '{"original": "data"}', + headers = { + ["Content-Type"] = "application/json" + } + }) + + if not res then + ngx.status = 500 + ngx.say("Request failed: " .. (err or "unknown")) + return + end + + ngx.status = res.status + ngx.say(res.body) + } + } +--- request +GET /t +--- response_body_like +.*final_response.* +--- no_error_log +[error]