SOAP tutorial - Receive SOAP messages

This tutorial configures a SOAP channel that accepts requests from external systems and implements a Python service that processes them.

If your background is JSON, keep in mind that SOAP is based on XML. Before JSON, XML was a popular protocol for describing requests and responses, and SOAP added extra features on top of XML, like security or message attachments.

There are very few new SOAP deployments, but there are some big existing ones, including in healthcare, which is what this tutorial will use for illustration purposes.

If your own use case is different (e.g. B2B), all the principles of working with SOAP will still apply as described in this documentation.

Deploy code

Start by deploying this code, that's the service we'll be exposing via SOAP.

# -*- 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)

Create the channel

Start by creating a SOAP channel in your Zato dashboard at Connections > Channels > SOAP.

  • 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.

This code does not refer to the envelope, header or body. The service receives the operation element - the single child of the request's body - and reads its fields with the dot access that outgoing calls use for responses: nested elements are deeper dot access, repeated elements are lists, XML attributes go through brackets.

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

Return a response

A response uses the same message objects - build one 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 handles 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.

Return SOAP faults

A rejected request is an ordinary Python exception - the channel converts it to 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 MTOM attachments

Document submissions contain binary content, and document-exchange profiles require it to be sent 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 is read as bytes, the same as a value the sender inlined
        document = request.Document

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

        # Bytes are assigned like any other value - 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 request metadata

Everything the channel established about the request is available 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.