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 && echoProtect 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; }
doneSteps
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()
PYTHONStart 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>
TEMPLATECreate 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")" = 404Both 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_DIRExpected: the fixed Route, container, and three guide-created files are absent.
Troubleshoot
| Symptom | Cause | Fix |
|---|---|---|
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. |
Related
- Body Transformer (opens in Plugin Hub docs) — lists supported formats, template functions, and plugin parameters.
- Proxy Rewrite (opens in Plugin Hub docs) — documents the upstream header rewrite used here.
- Transcode HTTP to gRPC — adapts HTTP clients to a gRPC upstream.