Migrate a SOAP Endpoint to REST via Gateway Transformation

Legacy SOAP services rarely disappear on schedule — a payment settlement backend, an insurance quoting engine, or a government records API often outlives three generations of the clients that call it. The pragmatic path is not a rewrite but a facade: front the SOAP/XML backend with a REST/JSON interface at the gateway, so new consumers speak clean JSON over predictable HTTP verbs while the backend keeps receiving the exact SOAP envelopes it was built for. This runbook walks the full transformation — request-side JSON-to-XML with SOAPAction injection, response-side XML-to-JSON, fault mapping, and an incremental cutover — using Kong 3.x transformer plugins and Envoy 1.32+ with ext_proc.

Prerequisite concepts

This is an applied case of the broader request and response transformation discipline: you are rewriting both the body and the headers in flight, in both directions, within the middleware chain that every request traverses. If you have not yet worked through payload rewriting mechanics, read rewriting JSON payloads on the fly first — the SOAP facade is that same technique with an XML serialization boundary bolted on. The transform runs as an ordered set of plugins, so plugin priority and phase (access vs body_filter) matter exactly as they do for any other policy in the chain.

What actually has to change on the wire

A REST client sends POST /v2/quotes with a compact JSON body. The SOAP backend expects a full envelope with a namespace-qualified operation element, a SOAPAction header, and Content-Type: text/xml. The gateway sits between them and performs four discrete rewrites:

  1. Request body: JSON object → SOAP envelope (wrap the operation, map fields into namespaced elements).
  2. Request headers: inject SOAPAction, force Content-Type: text/xml; charset=utf-8, strip client headers the backend rejects.
  3. Response body: SOAP envelope → flat JSON (unwrap soap:Body, lift the result element).
  4. Response faults: detect soap:Fault inside a 200 OK, rewrite the HTTP status, and emit a normalized JSON error.
Gateway SOAP-to-REST transformation flow A REST client sends JSON to the gateway. In the request phase the gateway wraps the JSON in a SOAP envelope and injects a SOAPAction header, then forwards text/xml to the legacy SOAP backend. In the response phase the gateway unwraps the SOAP body to JSON, or detects a soap:Fault and maps it to an HTTP error status. REST Client POST /v2/quotes JSON Request phase JSON → SOAP envelope inject SOAPAction Content-Type: text/xml Response phase SOAP body → JSON soap:Fault → HTTP 4xx/5xx normalized error JSON Legacy SOAP WSDL / XML document/literal text/xml envelope → ← SOAP response / fault JSON ←

Kong 3.x: declarative facade with transformer plugins

Kong ships the request-transformer and response-transformer plugins for header and simple body manipulation, but neither does structural XML↔JSON conversion. For the envelope wrapping and unwrapping you need a scripted step: the pre-function / post-function serverless plugins (Kong’s serverless-functions), which run Lua in the access and body_filter phases. Combine them so headers are handled declaratively and the structural conversion is scripted.

# Kong 3.x — declarative config for a SOAP-to-REST facade route
_format_version: "3.0"

services:
  - name: quoting-soap
    url: http://quoting.internal:8080/QuotingService   # legacy SOAP endpoint
    connect_timeout: 3000
    read_timeout: 8000       # SOAP backends are slow; size above p99
    write_timeout: 5000

    routes:
      - name: quotes-rest
        paths: ["/v2/quotes"]
        methods: ["POST"]
        strip_path: true

    plugins:
      # 1. Inject SOAP-required headers in the access phase
      - name: request-transformer
        config:
          replace:
            headers:
              - "Content-Type:text/xml; charset=utf-8"
          add:
            headers:
              # Quoted value MUST match the WSDL soapAction attribute
              - 'SOAPAction:"http://quoting.internal/GetQuote"'
          remove:
            headers:
              - accept-encoding   # avoid gzip'd XML the Lua parser can't read

      # 2. Wrap the inbound JSON body into a SOAP envelope (access phase)
      - name: pre-function
        config:
          access:
            - |
              local cjson = require "cjson.safe"
              local body = kong.request.get_raw_body()
              local data = cjson.decode(body) or {}
              local env = string.format([[<?xml version="1.0" encoding="UTF-8"?>
              <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
                             xmlns:q="http://quoting.internal/">
                <soap:Body>
                  <q:GetQuote>
                    <q:productId>%s</q:productId>
                    <q:amount>%s</q:amount>
                  </q:GetQuote>
                </soap:Body>
              </soap:Envelope>]],
                kong.htmlEscape and kong.htmlEscape(data.productId) or data.productId or "",
                tostring(data.amount or ""))
              kong.service.request.set_raw_body(env)

      # 3. Convert the SOAP response back to JSON + map faults (body_filter)
      - name: post-function
        config:
          header_filter:
            - |
              kong.response.set_header("Content-Type", "application/json")
              -- clear upstream Content-Length; body size changes after rewrite
              kong.response.clear_header("Content-Length")
          body_filter:
            - |
              local xml = kong.response.get_raw_body()
              if not xml then return end
              local cjson = require "cjson.safe"
              -- Fault detection first: SOAP faults arrive inside HTTP 200
              local faultcode = xml:match("<faultcode>(.-)</faultcode>")
              if faultcode then
                local msg = xml:match("<faultstring>(.-)</faultstring>") or "upstream fault"
                local status = 502
                if faultcode:find("Client") then status = 400 end
                if msg:lower():find("auth") then status = 401 end
                kong.response.set_status(status)
                kong.response.set_raw_body(cjson.encode({
                  error = { code = faultcode, message = msg }}))
                return
              end
              -- Happy path: lift the result fields out of the envelope
              local quoteId = xml:match("<.-quoteId>(.-)</.-quoteId>")
              local premium = xml:match("<.-premium>(.-)</.-premium>")
              kong.response.set_raw_body(cjson.encode({
                quoteId = quoteId, premium = tonumber(premium) }))

Two Kong-specific hazards are baked into the config above. First, always clear_header("Content-Length") after you resize the body — a stale length header truncates the rewritten JSON. Second, strip accept-encoding on the request so the backend does not gzip the XML; a compressed body reaches your body_filter as binary the string matcher cannot read. For anything beyond flat schemas, replace the string.match extraction with a real parser (xml2lua) packaged into a custom plugin — regex over XML is acceptable only for shallow, well-known responses.

Envoy 1.32+: ext_proc for structural conversion

Envoy has no native SOAP plugin, and its header_to_metadata and Lua filters are not built for full-document XML parsing. The production pattern is the external processing filter (ext_proc), which streams request and response bodies to a gRPC service you own — written in the language with the best XML/JSON libraries for your team. Envoy handles routing, timeouts, and retries; your ext_proc server owns the envelope logic. A lightweight Lua filter can still handle the header injection so the sidecar service only sees bodies.

# Envoy 1.32+ — HTTP filter chain: Lua for headers, ext_proc for bodies
http_filters:
  # Inject SOAP headers inline; cheap, no external hop
  - name: envoy.filters.http.lua
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
      default_source_code:
        inline_string: |
          function envoy_on_request(handle)
            handle:headers():replace("content-type", "text/xml; charset=utf-8")
            handle:headers():replace("soapaction", "\"http://quoting.internal/GetQuote\"")
          end
          function envoy_on_response(handle)
            handle:headers():replace("content-type", "application/json")
          end
  # Stream bodies to the transform service for envelope <-> JSON conversion
  - name: envoy.filters.http.ext_proc
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor
      grpc_service:
        envoy_grpc:
          cluster_name: soap_transform_svc
        timeout: 5s
      processing_mode:
        request_body_mode: BUFFERED     # need the whole JSON to build the envelope
        response_body_mode: BUFFERED     # need the whole SOAP doc to unwrap it
        request_header_mode: SEND
        response_header_mode: SEND
      failure_mode_allow: false          # fail closed: a broken transform must not leak XML
  - name: envoy.filters.http.router
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

BUFFERED mode is mandatory here — you cannot build a SOAP envelope or map a fault from a partial body, so streaming modes do not apply. Set failure_mode_allow: false so that if the transform service is down, the request fails rather than forwarding raw JSON to a backend that will reject it (or, worse, returning raw XML to a REST client). Your ext_proc server maps the soap:Fault to a gRPC response that sets an :status header override, which Envoy applies to the client response. Size the ext_proc timeout below the route timeout so a slow transform surfaces as a clean 504 rather than a hung connection.

Decision matrix

Concern Kong 3.x (transformer + serverless) Envoy 1.32+ (Lua + ext_proc)
Header injection (SOAPAction, Content-Type) request-transformer declaratively inline Lua filter
Structural XML↔JSON Lua in pre/post-function or custom plugin external gRPC processor (any language)
Best for flat schemas Strong — fully declarative + short Lua Overkill; ext_proc hop adds latency
Best for nested / RPC-encoded WSDL Custom Lua plugin with xml2lua Strong — full parser in the transform svc
Fault mapping in-line in body_filter in transform svc via :status override
Operational surface one gateway process gateway + sidecar transform service
Latency added ~0.5–2 ms (in-process Lua) ~2–6 ms (extra gRPC round trip, buffered)

Choose Kong when the WSDL is document/literal with shallow schemas and the team wants a single deployable. Choose Envoy ext_proc when the envelopes are deeply nested, use xsi:type polymorphism, or when you already run an Envoy mesh and want the SOAP knowledge isolated in a testable service rather than embedded in gateway config.

Incremental cutover

Do not flip all traffic at once. Stand the facade up beside the existing SOAP endpoint and move traffic in stages:

  1. Shadow. Mirror a copy of live SOAP traffic through the facade and diff the JSON-decoded facade response against the SOAP response. Kong’s request-mirroring and Envoy’s request_mirror_policies both mirror without affecting the client.
  2. Canary a single consumer. Use header or path-based routing to send one internal consumer’s traffic through /v2/quotes while everyone else stays on SOAP. Watch fault-mapping accuracy under real payload variety.
  3. Ramp by weight. Increase the weighted split to the facade as confidence grows, keeping the raw SOAP path reachable so a weight: 0 shift is an instant fail-back.
  4. Decommission. Retire the SOAP endpoint only after the facade has carried full traffic through at least one peak cycle with no fault-mapping regressions.

Gotchas and failure signals

  • Faults hidden in 200 OK. SOAP delivers errors inside a successful HTTP envelope. If you map status from the HTTP line instead of parsing soap:Fault, every failed call looks like a success. Always inspect the body first in the response phase.
  • SOAPAction quoting. The header value is a quoted URI. Sending it unquoted, or with the wrong URI, produces a backend 500 or a Cannot find dispatch method fault. Copy the exact soapAction from the WSDL binding.
  • Namespace prefixes are not fixed. The backend may return ns2:premium today and ns5:premium after a redeploy. Match on local name (<.-premium>), never on a hard-coded prefix, or use a namespace-aware parser.
  • Gzip’d XML. If you forget to strip accept-encoding, the response body reaches your transform as compressed bytes and every match returns nil. Symptom: empty JSON {} on the happy path.
  • Content-Length drift. Rewriting the body without clearing Content-Length truncates the response. Symptom: JSON parse errors on the client for large quotes only.
  • MTOM / attachments. SOAP with MTOM binary attachments cannot be flattened to JSON by a template transform — route those operations to a dedicated service, not the facade.

Validation

  • SOAPAction header injected as a quoted string exactly matching the WSDL binding.
  • Request Content-Type forced to the backend’s expected text/xml (1.1) or application/soap+xml (1.2).
  • accept-encoding stripped so the transform reads uncompressed XML.
  • Content-Length cleared after every body rewrite in both directions.
  • soap:Fault detected inside HTTP 200 and mapped to a 4xx/5xx JSON error before any success path runs.
  • Response field extraction matches on local element name, not a hard-coded namespace prefix.
  • Envoy ext_proc runs failure_mode_allow: false so a broken transform fails closed.
  • Facade validated against the SOAP path via shadow traffic before any consumer is cut over.
  • Raw SOAP endpoint kept reachable for instant fail-back until the facade survives one peak cycle.

FAQ

Can a gateway alone fully convert SOAP to REST without a custom service?

For simple document/literal WSDL operations with flat request and response schemas, gateway transformer plugins can handle the full XML-to-JSON and JSON-to-XML mapping declaratively. For RPC/encoded WSDL, deeply nested schemas, xsi:type polymorphism, or operations that require field-level renaming and type coercion, you need a scripted transform step — Kong’s serverless functions or Envoy’s ext_proc external processor — because template plugins cannot express conditional logic. A pure declarative facade is realistic for perhaps 60 to 70 percent of legacy operations; budget a code path for the rest.

How do I map SOAP faults to REST HTTP status codes?

SOAP returns faults inside a 200 OK envelope with a soap:Fault element, so you must inspect the response body, not the status line. Parse the faultcode: Client faults map to 400, Server faults to 502 or 500, and authentication or authorization faults surface from the faultstring to 401 or 403. Rewrite the status in the response phase and replace the fault envelope with a normalized JSON error object. Never let a raw soap:Fault reach a REST consumer as a 200 — clients will treat a failed call as success.

Why does the legacy backend reject requests with a 500 after I add the gateway?

The most common cause is a missing or wrong SOAPAction HTTP header. Many SOAP stacks route the operation by SOAPAction, and document/literal services expect it as a quoted string matching the WSDL soapAction attribute. Inject it in the request phase per route. The second most common cause is a Content-Type mismatch: SOAP 1.1 expects text/xml with charset, SOAP 1.2 expects application/soap+xml. Set the exact Content-Type the backend’s WSDL binding declares.

How do I cut over from SOAP to the REST facade without downtime?

Run the facade in parallel with the existing SOAP endpoint. Route a small percentage of a chosen consumer’s traffic through the REST facade using weighted routing or header-based routing, compare responses against the SOAP path with shadow traffic, then increase the weight as confidence grows. Keep the raw SOAP endpoint reachable throughout so you can fail back instantly by shifting the weight to zero. Only decommission the SOAP path after the facade has carried full production traffic through at least one peak cycle.


Parent: Request & Response Transformation