Docs
Apache APISIXManage traffic and APIsTransformationConvert JSON to XML

Convert JSON to XML

Configure Apache APISIX to transform a JSON request into a SOAP XML request and transform the XML response back to JSON against a controlled local service.

Applies to:Apache APISIX 3.18.0

Deployments:DockerRoles:API developerTime:20 minutesImpact:Changes live traffic

Goal

Use body-transformer request and response templates so a client can send JSON to APISIX while a controlled upstream receives and returns SOAP XML. Validate the transformed response and clean up all resources.

Before you begin

  • Complete Get APISIX with the Docker quickstart.
  • Install Docker, cURL, and jq.
  • Use a shell that supports Bash or Zsh heredocs.

Set the endpoints and read the Admin API key without placing it in shell history:

export APISIX_ADMIN_URL="http://127.0.0.1:9180"
export GATEWAY_URL="http://127.0.0.1:9080"
export GUIDE_DIR="$(pwd)"
read -rsp "Admin API key (press Enter for an unchanged quickstart): " APISIX_ADMIN_KEY && export APISIX_ADMIN_KEY && echo

Protect the fixed Route, container, and local files used by this guide:

test "$(curl -sS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}' "${APISIX_ADMIN_URL}/apisix/admin/routes/json-xml-guide-route" -H "X-API-KEY: ${APISIX_ADMIN_KEY}")" = 404 || { echo 'Stop: route json-xml-guide-route already exists'; exit 1; }
test -z "$(docker ps -aq --filter name=^/json-xml-guide-upstream$)" || { echo 'Stop: container json-xml-guide-upstream already exists'; exit 1; }
for file in json-xml-guide-soap.py json-xml-guide-request.tmpl json-xml-guide-response.tmpl; do
  test ! -e "${file}" || { echo "Stop: ${file} already exists"; exit 1; }
done

Steps

Step 1: Start a controlled SOAP upstream

Create a minimal server that accepts only the expected transformed XML and returns a deterministic SOAP response:

cat > json-xml-guide-soap.py <<'PYTHON'
from http.server import BaseHTTPRequestHandler, HTTPServer

RESPONSE = b'''<?xml version="1.0" encoding="UTF-8"?>
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:getCountryResponse xmlns:ns0="http://spring.io/guides/gs-producing-web-service">
      <ns0:country>
        <ns0:name>Spain</ns0:name>
        <ns0:population>46704314</ns0:population>
        <ns0:capital>Madrid</ns0:capital>
        <ns0:currency>EUR</ns0:currency>
      </ns0:country>
    </ns0:getCountryResponse>
  </soap-env:Body>
</soap-env:Envelope>'''

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
        if b"<ns0:name>Spain</ns0:name>" not in body:
            self.send_error(400, "expected transformed Spain request")
            return
        self.send_response(200)
        self.send_header("Content-Type", "text/xml; charset=utf-8")
        self.send_header("Content-Length", str(len(RESPONSE)))
        self.end_headers()
        self.wfile.write(RESPONSE)

    def log_message(self, format, *args):
        return

HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
PYTHON

Start the pinned Python image only on the APISIX quickstart network and wait for its listener:

docker run -d --name json-xml-guide-upstream \
  --network apisix-quickstart-net \
  -v "${GUIDE_DIR}/json-xml-guide-soap.py:/work/server.py:ro" \
  python:3.13.7-alpine python /work/server.py
for attempt in {1..15}; do
  docker exec apisix-quickstart bash -c '</dev/tcp/json-xml-guide-upstream/8080' >/dev/null 2>&1 && break
  sleep 1
done
docker exec apisix-quickstart bash -c '</dev/tcp/json-xml-guide-upstream/8080'

What changed: APISIX has a deterministic local SOAP upstream that rejects a request unless the JSON-to-XML transformation produced the expected country name.

Step 2: Create the transformation templates

Create a request template using lua-resty-template syntax:

cat > json-xml-guide-request.tmpl <<'TEMPLATE'
<?xml version="1.0"?>
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:getCountryRequest xmlns:ns0="http://spring.io/guides/gs-producing-web-service">
      <ns0:name>{{_escape_xml(name)}}</ns0:name>
    </ns0:getCountryRequest>
  </soap-env:Body>
</soap-env:Envelope>
TEMPLATE

Create a response template that reads the decoded XML fields and emits JSON:

cat > json-xml-guide-response.tmpl <<'TEMPLATE'
{
  "status":"{{_ctx.var.status}}",
  "currency":"{{Envelope.Body.getCountryResponse.country.currency}}",
  "population":{{Envelope.Body.getCountryResponse.country.population}},
  "capital":"{{Envelope.Body.getCountryResponse.country.capital}}",
  "name":"{{Envelope.Body.getCountryResponse.country.name}}"
}
TEMPLATE

_escape_xml() protects the request value before inserting it into XML. _ctx.var.status reads the upstream status, while the other expressions read the decoded response body.

What changed: both templates exist as inspectable local files without manual JSON escaping or base64 encoding.

Step 3: Create the transformation Route

Use jq --rawfile to encode the complete templates into a fixed-ID Route safely:

jq -n \
  --rawfile request_template json-xml-guide-request.tmpl \
  --rawfile response_template json-xml-guide-response.tmpl \
  '{
    methods:["POST"],
    uri:"/json-xml-guide",
    plugins:{
      "body-transformer":{
        request:{template:$request_template,input_format:"json"},
        response:{template:$response_template,input_format:"xml"}
      },
      "proxy-rewrite":{headers:{set:{"Content-Type":"text/xml"}}}
    },
    upstream:{
      type:"roundrobin",
      nodes:{"json-xml-guide-upstream:8080":1}
    }
  }' | \
curl -sS --connect-timeout 3 --max-time 15 "${APISIX_ADMIN_URL}/apisix/admin/routes/json-xml-guide-route" -X PUT \
  -H "X-API-KEY: ${APISIX_ADMIN_KEY}" -H "Content-Type: application/json" \
  --data-binary @- | jq -e '.value.id == "json-xml-guide-route" and .value.plugins["body-transformer"].request.input_format == "json" and .value.plugins["body-transformer"].response.input_format == "xml"'

What changed: POST /json-xml-guide converts JSON to XML before proxying, sets the upstream content type, and converts the SOAP XML response back to JSON.

Validate

Require an exact transformed response, then prove that the Route does not accept GET:

curl --fail-with-body -sS --connect-timeout 3 --max-time 15 \
  "${GATEWAY_URL}/json-xml-guide" \
  -X POST -H "Content-Type: application/json" -d '{"name":"Spain"}' | \
  jq -e '. == {status:"200",currency:"EUR",population:46704314,capital:"Madrid",name:"Spain"}'
test "$(curl -sS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' \
  "${GATEWAY_URL}/json-xml-guide")" = 404

Both assertions must pass. The local server's request check and the exact client response together prove both transformation directions.

Roll back / Clean up

Delete the Route, remove the disposable upstream, and delete the local source and template files:

curl -sS --connect-timeout 3 --max-time 15 -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/routes/json-xml-guide-route" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
docker rm -f json-xml-guide-upstream
rm -f json-xml-guide-soap.py json-xml-guide-request.tmpl json-xml-guide-response.tmpl
unset APISIX_ADMIN_URL APISIX_ADMIN_KEY GATEWAY_URL GUIDE_DIR

Expected: the fixed Route, container, and three guide-created files are absent.

Troubleshoot

SymptomCauseFix
The request returns 400 expected transformed Spain request.The request template did not produce the expected XML element or a different country was sent.Inspect the non-secret template file and send exactly {"name":"Spain"} before changing the controlled server.
APISIX reports an XML decode or template error.The response template path does not match the SOAP response, or a template was truncated.Compare the complete heredocs and recreate the fixed Route with jq --rawfile.
The request returns 502.APISIX cannot reach the local SOAP container.Verify the container is attached to apisix-quickstart-net and rerun the TCP readiness check.