Friday 30 December 2016

PHP: using SoapClient to consume a SOAP web service

G'day:
Yeah, OK, just don't ask why I'm needing to consume SOAP web services. It's Adobe's fault. Not because of ColdFusion for a change, but because an app they offer which only exposes its API via SOAP. Because it's still 2006 at Adobe, it seems.

For the last few weeks, this has been running through my head:



But anyway.

Fighting with the Adobe web service was a right pain in the arse, but I won't go into that - it's not useful info to share - but I did have to monkey around with how PHP consumes SOAP web services, and piecing it together took a chunk of googling for me to get my brain around it, so I thought I'd write it up here.

I hasten to add that most of the hassle getting it to work was because of the shitty Adobe web service being unhelpful, so it's perhaps not a challenge for most other people.

I cannot deal with the Adobe web service in this article as it's subscription-only, and it's kinda work-related stuff I'm not allowed to expose here. So I knocked together my own web service to consume. I used CFML for this as all it takes to create a SOAP web service in CFML is to give a method an access qualifier of remote, and have it web-accessible. ColdFusion handles everything else.

Here's the web service code (I'll keep the CFML code to a minimum, but it's all on GitHub anyhow):

import me.adamcameron.accounts.*;

component wsversion=1 {

    remote Invoice function getById(numeric id) returnformat="wddx" {
        var address = new Address(1, "London", "United Kingdom", "E18");

        var account = new PersonalAccount(2, "Adam", "Cameron", "1970-02-17", address);

        var penguin = new Product(3, "Penguin", 4.56);
        var pangolin = new Product(7, "Pangolin", 8.90);
        var platypus = new Product(11, "Playtpus", 12.13);

        var lines = [
            new InvoiceLine(14, penguin, 15, 16.17),
            new InvoiceLine(18, pangolin, 19, 20.21),
            new InvoiceLine(22, platypus, 23, 24.25)
        ];

        invoice = new Invoice(id, account, lines);

        return invoice;        
    }

}

Note that I should not have to state the wsversion there, but for some reason I could not get the default - version 2 - to work, so I had to force it to be version 1. I didn't look into why it didn't work on version 2, as I really didn't want to spend more time than necessary on the CFML code for this exercise.

Other than that, this code is straight forward: it returns an Invoice object which comprises a Person (with an Address), and an array of InvoiceLines each of which have a Product. the modelling for those is the same as in the receiving PHP code, further down.

On the PHP side of things, I just call this code to consume it:

<?php

namespace me\adamcameron\accounts;

require __DIR__ . '/model.php';

$wsdl = "http://localhost:8516/cfml/webservices/soap/accounts/public/Invoices.cfc?wsdl";


$options = [
    'trace' => true
];

$client = new \SoapClient($wsdl, $options);

$invoice = $client->getById(2011);

var_dump($invoice);
echo "==============================" . PHP_EOL . PHP_EOL;
var_dump($client->__getLastResponse());

So it's simply a matter of creating a SoapClient with the WSDL URL and some options. I'm using the trace option here so I can do that call to __getLastResponse.

Let's have a look at the raw SOAP response first (warning: it's pretty turgid reading):

<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <soapenv:Body>
        <ns1:getByIdResponse xmlns:ns1="http://public.accounts.soap.webservices.cfml" soapenv:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
            <getByIdReturn xmlns:ns2="http://accounts.adamcameron.me" xsi:type="ns2:Invoice">
                <account xsi:type="ns2:PersonalAccount">
                    <address xsi:type="ns2:Address">
                        <country xsi:type="xsd:string">United Kingdom</country>
                        <id xsi:type="xsd:double">1.0</id>
                        <localPart xsi:type="xsd:string">London</localPart>
                        <postcode xsi:type="xsd:string">E18</postcode>
                    </address>
                    <dateOfBirth xsi:type="xsd:string">1970-02-17</dateOfBirth>
                    <firstName xsi:type="xsd:string">Adam</firstName>
                    <id xsi:type="xsd:double">2.0</id>
                    <lastName xsi:type="xsd:string">Cameron</lastName>
                </account>
                <id xsi:type="xsd:double">2011.0</id>
                <items xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/" soapenc:arrayType="xsd:anyType[3]" xsi:type="soapenc:Array">
                    <items xsi:type="ns2:InvoiceLine">
                        <count xsi:type="xsd:double">15.0</count>
                        <id xsi:type="xsd:double">14.0</id>
                        <price xsi:type="xsd:double">16.17</price>
                        <product xsi:type="ns2:Product">
                            <description xsi:type="xsd:string">Penguin</description>
                            <id xsi:type="xsd:double">3.0</id>
                            <rrp xsi:type="xsd:double">4.56</rrp>
                        </product>
                    </items>
                    <items xsi:type="ns2:InvoiceLine">
                        <count xsi:type="xsd:double">19.0</count>
                        <id xsi:type="xsd:double">18.0</id>
                        <price xsi:type="xsd:double">20.21</price>
                        <product xsi:type="ns2:Product">
                            <description xsi:type="xsd:string">Pangolin</description>
                            <id xsi:type="xsd:double">7.0</id>
                            <rrp xsi:type="xsd:double">8.9</rrp>
                        </product>
                    </items>
                    <items xsi:type="ns2:InvoiceLine">
                        <count xsi:type="xsd:double">23.0</count>
                        <id xsi:type="xsd:double">22.0</id>
                        <price xsi:type="xsd:double">24.25</price>
                        <product xsi:type="ns2:Product">
                            <description xsi:type="xsd:string">Playtpus</description>
                            <id xsi:type="xsd:double">11.0</id>
                            <rrp xsi:type="xsd:double">12.13</rrp>
                        </product>
                    </items>
                </items>
            </getByIdReturn>
        </ns1:getByIdResponse>
    </soapenv:Body>
</soapenv:Envelope>

Wot a bloody mouthful!

PHP takes all that in its stride, and rehydrates that into an object:

object(stdClass)#2 (3) {
    ["account"]=>
    object(stdClass)#3 (5) {
        ["address"]=>
        object(stdClass)#4 (4) {
            ["country"]=>
            string(14) "United Kingdom"
            ["id"]=>
            float(1)
            ["localPart"]=>
            string(6) "London"
            ["postcode"]=>
            string(3) "E18"
        }
        ["dateOfBirth"]=>
        string(10) "1970-02-17"
        ["firstName"]=>
        string(4) "Adam"
        ["id"]=>
        float(2)
        ["lastName"]=>
        string(7) "Cameron"
    }
    ["id"]=>
    float(2011)
    ["items"]=>
    array(3) {
        [0]=>
        object(stdClass)#5 (4) {
            ["count"]=>
            float(15)
            ["id"]=>
            float(14)
            ["price"]=>
            float(16.17)
            ["product"]=>
            object(stdClass)#6 (3) {
                ["description"]=>
                string(7) "Penguin"
                ["id"]=>
                float(3)
                ["rrp"]=>
                float(4.56)
            }
        }
        [1]=>
        object(stdClass)#7 (4) {
            ["count"]=>
            float(19)
            ["id"]=>
            float(18)
            ["price"]=>
            float(20.21)
            ["product"]=>
            object(stdClass)#8 (3) {
                ["description"]=>
                string(8) "Pangolin"
                ["id"]=>
                float(7)
                ["rrp"]=>
                float(8.9)
            }
        }
        [2]=>
        object(stdClass)#9 (4) {
            ["count"]=>
            float(23)
            ["id"]=>
            float(22)
            ["price"]=>
            float(24.25)
            ["product"]=>
            object(stdClass)#10 (3) {
                ["description"]=>
                string(8) "Playtpus"
                ["id"]=>
                float(11)
                ["rrp"]=>
                float(12.13)
            }
        }
    }
}

That's cool, but if we look at the SOAP response, we do have the object information in there too. Here's an extract:

xsi:type="ns2:Invoice"&gt;
    &lt;account xsi:type="ns2:PersonalAccount"&gt;
        &lt;address xsi:type="ns2:Address"&gt;
            &lt;country xsi:type="xsd:string"&gt;United Kingdom&lt;/country&gt;
            &lt;id xsi:type="xsd:double"&gt;1.0&lt;/id&gt;
            &lt;localPart xsi:type="xsd:string"&gt;London&lt;/localPart&gt;
            &lt;postcode xsi:type="xsd:string"&gt;E18&lt;/postcode&gt;
        &lt;/address&gt;
        &lt;dateOfBirth xsi:type="xsd:string"&gt;1970-02-17&lt;/dateOfBirth&gt;
        &lt;firstName xsi:type="xsd:string"&gt;Adam&lt;/firstName&gt;
        &lt;id xsi:type="xsd:double"&gt;2.0&lt;/id&gt;
        &lt;lastName xsi:type="xsd:string"&gt;Cameron&lt;/lastName&gt;
    &lt;/account&gt;
    &lt;id xsi:type="xsd:double"&gt;2011.0&lt;/id&gt;
    &lt;items xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/" soapenc:arrayType="xsd:anyType[3]" xsi:type="soapenc:Array"&gt;
        &lt;items xsi:type="ns2:InvoiceLine"&gt;
            &lt;count xsi:type="xsd:double"&gt;15.0&lt;/count&gt;
            &lt;id xsi:type="xsd:double"&gt;14.0&lt;/id&gt;
            &lt;price xsi:type="xsd:double"&gt;16.17&lt;/price&gt;
            &lt;product xsi:type="ns2:Product"&gt;
                &lt;description xsi:type="xsd:string"&gt;Penguin&lt;/description&gt;
                &lt;id xsi:type="xsd:double"&gt;3.0&lt;/id&gt;
                &lt;rrp xsi:type="xsd:double"&gt;4.56&lt;/rrp&gt;
            &lt;/product&gt;
        &lt;/items&gt;
        &lt;items xsi:type="ns2:InvoiceLine"&gt;
            &lt;!-- etc &gt;


Hidden away in there we see there's an Invoice, PersonalAccount, Address, InvoiceLines and Products.

We can leverage this information to point PHP at classes to use when rehydrating the response, which is cool. We give the SoapClient a type map:

$options = [
    'trace' => true,
    'typemap' => [[
        'type_ns' => 'http://accounts.adamcameron.me',
        'type_name' => 'Address',
        'from_xml' => ['\me\adamcameron\accounts\Address', 'createFromXml']
    ],[
        'type_ns' => 'http://accounts.adamcameron.me',
        'type_name' => 'Account',
        'from_xml' => ['\me\adamcameron\accounts\Account', 'createFromXml']
    ],[
        'type_ns' => 'http://accounts.adamcameron.me',
        'type_name' => 'Product',
        'from_xml' => ['\me\adamcameron\accounts\Product', 'createFromXml']
    ],[
        'type_ns' => 'http://accounts.adamcameron.me',
        'type_name' => 'InvoiceLine',
        'from_xml' => ['\me\adamcameron\accounts\InvoiceLine', 'createFromXml']
    ],[
        'type_ns' => 'http://accounts.adamcameron.me',
        'type_name' => 'Invoice',
        'from_xml' => ['\me\adamcameron\accounts\Invoice', 'createFromXml']
    ]]
];

$client = new \SoapClient($wsdl, $options);

This maps the SOAP type to a PHP class, and method to use to rehydrate the object.

Running the code again with the typemap yields a fully-modelled result:

object(me\adamcameron\accounts\Invoice)#3 (3) {
    // ...
    ["account"]=>
    object(me\adamcameron\accounts\Account)#5 (5) {
        // ...
        ["address"]=>
        object(me\adamcameron\accounts\Address)#9 (5) {
            // ...
        }
    }
    ["items"]=>
    array(3) {
        [0]=>
        object(me\adamcameron\accounts\InvoiceLine)#13 (4) {
            // ...
            ["product"]=>
            object(me\adamcameron\accounts\Product)#15 (3) {
                // ...
            }
            // ...
        }
        [1]=>
        object(me\adamcameron\accounts\InvoiceLine)#14 (4) {
            // ...
            ["product"]=>
            object(me\adamcameron\accounts\Product)#17 (3) {
                // ...
            }
            // ...
        }
        [2]=>
        object(me\adamcameron\accounts\InvoiceLine)#16 (4) {
            // ...

            ["product"]=>
            object(me\adamcameron\accounts\Product)#19 (3) {
                // ...
            }
            // ...
        }
    }
}

(I've elided the irrelevant stuff from that one).

So that's pretty handy.

I do wish I could provide the typemap with each call though if I wanted to, not just when creating the SoapClient object. Oh well: it was no huge hardship.

There's no trick on the class side of things at all. Here's the InvoiceLine class:

<?php

namespace me\adamcameron\accounts;

class InvoiceLine {

    public $id;
    public $product;
    public $count;
    public $price;
        
    function __construct(int $id, Product $product, int $count, float $price){
        $this->id = $id;
        $this->product = $product;
        $this->count = $count;
        $this->price = $price;
    }
    
    static function createFromXml ($xml){
        $sxe = is_string($xml) ? new \SimpleXMLElement($xml) : $xml;
        $obj = new InvoiceLine(
            (int)$sxe->id,
            Product::createFromXml($sxe->product),
            (int)$sxe->count,
            (float)$sxe->price
        );
        return $obj;            
    }
}


OK the one trick is that the SoapClient only passes the "top level" object's XML through to the top level method. So in this case SoapClient calls InvoiceLine->createFromXml as per the type map, but from there it's up to me to do the same for any other objects this one uses, for example a Product in this case.

Oh and the other trick is that because of this, I need that XML / string differentiator at the beginning of the method. SoapClient passes in a string, but once I've converted that to XML, that's what the other calls will receive (ie: Product::createFromXml receives XML, not a string). That took me a while to nut-out, but that's just me being a div.

Oh yeah, and don't forget to explicitly cast the values going into yer properties, otherwise you'll end up with XML in there, not the actual values. That threw me for a while as well :-/

All the rest of the PHP code for this is on GitHub too.

The problem I had with the Adobe web service is that it didn't bother including the type attributes in its responses, other than on the container element (so in my example the array of InvoiceItems would have had a type on it). This means I had to pass the entire collection container into a handler method which then read the XML tag names and hand-cranked a switch based on that as to what rehydration method to use. I'm gonna try to contrive a non-business-sensitive version of that to show how I dealt with it.

Now... I've received orders from a mate that I'm expected at the pub in an hour. So I had better get cracking. Sorry to make you think about SOAP. And Adobe.

Righto.

--
Adam