SOAP Tutorial - Accept SOAP requests without learning SOAP

A channel unwraps incoming envelopes into plain objects and your service answers with another - document submission as the running example.

A partner's system, a hospital or a government network needs to send SOAP requests to you, and their side is fixed: they will send envelopes, expect response elements and treat anything malformed as an error.

This tutorial shows how to be that endpoint with nothing but Python objects. A SOAP channel unwraps each incoming envelope before your service runs, the service reads the request with dot access and responds by assigning another object - no XML, no schemas, no WSDL compilation.

The running example is a healthcare document repository of the kind IHE document submission profiles prescribe - a system that accepts binary documents over SOAP and answers with a registry response. Everything shown here applies unchanged to any other SOAP interface you need to expose.

In this tutorial

  1. Create the channel
  2. Read the request
  3. Respond with an object
  4. Reject requests with faults
  5. Receive documents with MTOM
  6. Read the protocol context
  7. Deploy with enmasse

Remember: you can connect your AI copilot to Zato documentation.

Create the channel

Step 1. If you do not have Zato running yet, install it via Docker - it takes under 5 minutes.

Step 2. Open the web admin dashboard at http://localhost:8183, navigate to Connections > Channels > SOAP, click Create a new SOAP channel and fill in the form:

  • Name: Document Repository
  • URL path: /xds/repository
  • Service: the service that will handle the requests - you will deploy it in a moment, so any existing service works for now and you can change it later through Edit
  • SOAP action: urn:ihe:iti:2007:ProvideAndRegisterDocumentSet-b
  • SOAP version: 1.2
  • Click OK

Every field has a How does it work? link next to it that explains what the field does and when you need it.

From this moment, any SOAP client that posts an envelope to /xds/repository reaches your service - the channel takes care of parsing the envelope, whichever SOAP version and packaging the caller uses.

Read the request

Open the Zato IDE at http://localhost:8183, create a new file called repository_api.py, paste this code, click Deploy, and then assign the service to the channel through the channel's Edit dialog:

# -*- coding: utf-8 -*-

# Zato
from zato.server.service import Service

# ################################################################################################################################
# ################################################################################################################################

class ProvideAndRegisterDocumentSet(Service):
    """ Accepts document submissions of a healthcare document exchange.
    """

    name = 'repository-api.provide-and-register'

    def handle(self) -> 'None':

        # The channel already unwrapped the envelope - this is the operation
        # element from the message body, as a dot-accessed object.
        request = self.request.payload

        # Fields read by their names, no namespaces required
        patient_id = request.patientID
        comments = request.comments

        self.logger.info('Received a submission for %s: %s', patient_id, comments)

There is no envelope, header or body anywhere in this code. The service receives the operation element - the single child of the request's body - and reads its fields the same way outgoing calls read responses: nested elements are deeper dot access, repeated elements are lists, XML attributes go through brackets.

Services that want the raw bytes instead can still read self.request.raw - unwrapping is additive, nothing is taken away.

Try it: Post any SOAP envelope to http://localhost:17010/xds/repository with curl or any SOAP client - the service logs the fields it found.

Respond with an object

Responding mirrors reading - build a message and assign it to self.response.payload:

# -*- coding: utf-8 -*-

# Zato
from zato.common import SOAPMessage
from zato.server.service import Service

# ################################################################################################################################
# ################################################################################################################################

class ProvideAndRegisterDocumentSet(Service):

    name = 'repository-api.provide-and-register'

    def handle(self) -> 'None':

        request = self.request.payload

        self.logger.info('Received a submission for %s', request.patientID)

        # Build the reply - the channel wraps it in an envelope of the request's
        # SOAP version, inside the operation's response element.
        response = SOAPMessage()
        response.status = 'urn:ihe:iti:2007:ResponseStatusType:Success'

        self.response.payload = response

The caller receives a well-formed ProvideAndRegisterDocumentSetResponse element inside an envelope matching their request - the service never chooses the SOAP version, never names the response element and never sees the reply headers.

When the request included WS-Addressing, the reply automatically includes the addressing headers a conformant caller expects: a wsa:Action derived from the request's, a fresh wsa:MessageID and wsa:RelatesTo echoing the request's message id.

Reject requests with faults

A rejected request is an ordinary Python exception - the channel turns it into a SOAP fault of the request's version, with the right fault code for each dialect:

# -*- coding: utf-8 -*-

# Zato
from zato.common import SOAPMessage
from zato.common.exception import BadRequest
from zato.server.service import Service

# ################################################################################################################################
# ################################################################################################################################

class ProvideAndRegisterDocumentSet(Service):

    name = 'repository-api.provide-and-register'

    def handle(self) -> 'None':

        request = self.request.payload

        # This becomes a Sender fault with this exact message
        if not request.patientID:
            raise BadRequest(self.cid, 'patientID is required')

        response = SOAPMessage()
        response.status = 'urn:ihe:iti:2007:ResponseStatusType:Success'

        self.response.payload = response

Client errors - BadRequest and its relatives - become Sender faults with the exception's message as the fault reason. Any other exception becomes a Receiver fault with a generic message, and a Python traceback never reaches the wire either way.

Try it: Post an envelope without a patientID element - the fault comes back as soap:Sender with SOAP 1.2 and as soap:Client with SOAP 1.1, matching whatever the request was.

Receive documents with MTOM

Document submissions contain binary content, and document-exchange profiles require it to travel as MTOM parts rather than inline base64. On the receiving side this is invisible - a field the caller sent as an MTOM part reads back as plain bytes:

# -*- coding: utf-8 -*-

# stdlib
from hashlib import sha256

# Zato
from zato.common import SOAPMessage
from zato.server.service import Service

# ################################################################################################################################
# ################################################################################################################################

class ProvideAndRegisterDocumentSet(Service):

    name = 'repository-api.provide-and-register'

    def handle(self) -> 'None':

        request = self.request.payload

        # An MTOM part reads as bytes, exactly as if the sender had inlined it
        document = request.Document

        self.logger.info('Received a document of %d bytes', len(document))

        # Assigning bytes works the same way - with MTOM enabled on the channel,
        # the receipt leaves as an MTOM package, otherwise as inline base64.
        response = SOAPMessage()
        response.status = 'urn:ihe:iti:2007:ResponseStatusType:Success'
        response.receipt = sha256(document).digest()

        self.response.payload = response

For the response direction, tick the MTOM checkbox on the channel - from then on, any bytes value your service assigns leaves as an MTOM part. The raw MIME parts of the request are also available, as self.request.soap.attachments, for services that want to inspect content ids or content types.

Read the protocol context

Everything the channel established about the request rides along in self.request.soap, next to the payload and never mixed into it:

soap = self.request.soap

# The version and operation of the incoming request
self.logger.info('SOAP %s, operation %s', soap.soap_version, soap.operation)

# The request's WS-Addressing headers, when it had any
self.logger.info('wsa:MessageID %s', soap.addressing.message_id)

# What security enforcement established - e.g. the verified username
# of a UsernameToken-protected channel
self.logger.info('Verified user: %s', soap.security.username)

# The raw envelope bytes, for services that need them
self.logger.info('Envelope of %d bytes', len(soap.envelope))

Security itself is configured on the channel, not read in code - attach a WS-Security definition through the channel's Security dropdown and the platform rejects any request that does not satisfy it, with the correct fault, before your service ever runs.

Deploy with enmasse

Everything you configured through the Dashboard can also be defined declaratively in YAML and deployed with enmasse. This is the recommended approach for production, CI/CD pipelines, and version-controlled infrastructure.

The channel from this tutorial can be expressed as:

channel_soap:

  - name: Document Repository
    service: repository-api.provide-and-register
    url_path: /xds/repository
    soap_action: urn:ihe:iti:2007:ProvideAndRegisterDocumentSet-b
    soap_version: "1.2"
    use_mtom: true

Import it in the Dashboard under System → Config → Import enmasse, or mount the file under /opt/hot-deploy/enmasse/enmasse.yaml inside the container to have it imported on start.

What you built

  • A SOAP channel that accepts envelopes of either SOAP version, bare or multipart, on the URL path you chose
  • A service that reads requests through dot access and never parses XML
  • Object responses - the channel wraps them in envelopes matching each request, WS-Addressing reply headers included
  • Faults from exceptions - client errors as Sender faults with their message, everything else as Receiver faults with no internals leaking
  • MTOM in both directions - incoming parts as bytes, outgoing bytes as parts
  • Enmasse deployment - declarative YAML for automated, repeatable provisioning

Continue with the SOAP security tutorial to protect the channel with WS-Security, or read the SOAP integrations pillar for every building block: WS-Addressing, MTOM attachments, ebXML and more.


Schedule a meaningful demo

Book a demo with an expert who will help you build meaningful systems that match your ambitions

"For me, Zato Source is the only technology partner to help with operational improvements."

- John Adams
Program Manager of Channel Enablement at Keysight