ZF-6351: Zend_Soap_Server should facilitate the use of document-literal-wrapped WSDL

Description

For more information about "document-literal-wrapped WSDL" please see ZF-6349

As it is, PHP's SOAP extension can be used with a WSDL document making use of the "wrapped parameters" style (used by .NET and others). However, unwrapping is not done automatically. Consider the following method:


/**
 * This methods returns Hello $firstname $lastname.
 * @param string $firstname First name
 * @param string $lastname Last name
 * @return string
 */
public function helloYou($firstname, $lastname) {
    return "Hello {$firstname} {$lastname}";
}

A typical document-literal-wrapped WSDL document would wrap the two arguments into a "parameters" object declared by the message {{part}}, and whose complexType is defined in the {{types}} schema as a sequence of these parameters. Basically, after going through SoapServer, the {{helloYou()}} method would actually be called with only 1 argument: a stdClass object whose properties are {{firstname}} and {{lastname}}. Additionally, wrapping the return value is not automatic either, and one would have to return an array with $methodname.'Result' as the key and the actual return value as its value.

That is to say, the function above would have to be rewritten like this:


/**
 * This methods returns Hello $firstname $lastname.
 * @param string $firstname First name
 * @param string $lastname Last name
 * @return string
 */
public function helloYou($parameters) {
    return array('helloYouResult' => "Hello {$parameters->firstname} {$parameters->lastname}");
}

Beyond the fact this hack becomes quickly unreadable and completely depends on how the WSDL is made (whereas it should be transparent), it also poses a major problem when the WSDL is auto-generated from the same class by Zend_Soap_AutoDiscover (provided the ZF-6349 are applied as well as its dependencies). The function parameters and their description in the docblock are inconsistent. And this would throw an exception when doing reflection in Zend_Soap_AutoDiscover. BAD!

So, after this long explanation, I have the beginning of a solution but it's not integrated at all with Zend.. that is to say, the Zend_Soap_Server user must know he/she needs it and have the appropriate class, while ideally this should all be transparent.

The idea is to have a proxy class between SoapServer and the actual service class. This proxy is be able to intercept calls via the {{__call()}} magic method, to pre-process arguments and the return value appropriately (wrap/unwrap). Instead of using {{setClass()}} on Zend_Soap_Server, the user would have to do the following:


$proxy = new TestService_Proxy('TestService', array(), array('wrappedParts' => true));
$server->setObject($proxy);

The {{TestService_Proxy}} class (well, yes, it was for a test service.. don't mind the name ;)) is the following (inspired by Zend_Soap_Client):


<?php
class TestService_Proxy
{
    protected $_className;
    protected $_classInstance = null;
    protected $_wrappedParts = false;
    
    /**
     * TestService_Proxy creates an intermediate (proxy) class between the SOAP server
     * and the actual handling class, allowing pre-processing of function arguments and return values.
     * 
     * @param string $className name of the handling class to proxy.
     * @param array $classArgs arguments used to instantiate the handling class.
     * @param array $options proxy options.
     */
    public function __construct($className, $classArgs = array(), $options = array())
    {
        $class = new ReflectionClass($className);
        $constructor = $class->getConstructor();
        if ($constructor === null) {
            $this->_classInstance = $class->newInstance();
        } else {
            $this->_classInstance = $class->newInstanceArgs($classArgs);
        }
        $this->_className = $className;
        $this->_setOptions($options);
    }
    
    protected function _setOptions($options)
    {
        foreach ($options as $key => $value) {
            switch ($key) {
                case 'wrappedParts':
                    $this->_wrappedParts = $value;
                    break;
                default:
                    break;
            }
        }
    }
    
    protected function _getOptions()
    {
        $options = array();
        $options['wrappedParts'] = $this->_wrappedParts;
        return $options;
    }
    
    protected function _preProcessArguments($name, $arguments)
    {
        if ($this->_wrappedParts && count($arguments) == 1 && is_object($arguments[0])) {
            return get_object_vars($arguments[0]);
        } else {
            return $arguments;
        }
    }
    
    protected function _preProcessResult($name, $result)
    {
        if ($this->_wrappedParts) {
            return array($name.'Result' => $result);
        } else {
            return $result;
        }
    }
    
    public function __call($name, $arguments)
    {
        $result = call_user_func_array(array($this->_classInstance, $name), $this->_preProcessArguments($name, $arguments));
        return $this->_preProcessResult($name, $result);
    }
}

Now, this works pretty well and lets you write your service class without having to take the WSDL style into account. The problem, as I said, is it's completely NOT integrated with the rest of Zend. I would appreciate feedback and help on this, as I don't really know what approach to take to make use of it transparently in Zend_Soap_Server...

Comments

As far as I can see it is currently impossible to let .NET clients interact with a SOAP service without applying the above solution. Therefor I do not understand why this issue has a minor priority. Although the given solution works, I would make more sense to create a Zend_Soap_Server_DotNet class instead of creating a proxy class for a service. This would also be in line with the Zend_Soap_Client_DotNet class. Of course this Zend_Soap_Server_DotNet could create an internal wrapper around the service. One would have to overload the setClass and setObject of PHP's SoapServer class in order to make it work I think.

While the solution to the problem is well-documented, we really, really need to have a sample SOAP payload from a .NET client in order to create reasonable test cases here. Without this information, we can't judge for certain if the solution adequately addresses the issue, nor support the solution long-term.

Forget .NET, Matthew, have you tested it with a Zend_Soap_Client? Because it doesn't work. The Zend_Soap_client class is unable to correctly interact with a Zend_Soap_Server if the WSDL is auto-generated with Zend_Soap_AutoDiscover under a document/literal binding. Try it and you'll see. I spent two days trying to figure this out until I got to this page.

I'm encountering this exact problem with 1.10.8. RPC-literal makes it hard to do schema-validation, while Document-literal forces all the function signatures and docblocks to be inconsistent and flawed. As Shadow Caster mentions, the behavior isn't even consistent inside the Zend Framework ecosystem.

Thankfully, Fabien's proxy class solutions seems to work quite well, for now.

I implemented the proxy solution in Zend_Soap_Server_Proxy class and I changed the Zend_Soap_Server to manage the document-literal SOAP using an option parameter 'wsi_compliant' (WS-I standard used by .NET and Java/Axis).. If this option is set to true Zend_Soap_Server will use the proxy class to manage the service. You can set the wsi_compliant option passing by construct or using the setWsiCompliant($value) method.

$soap = new Zend_Soap_Server("http://url?wsdl", array('wsi_compliant'=>true)); or $soap->setWsiCompliant(true);

In order to produce a document-literal WSDL using the AutoDiscover component you have to use the following settings: $autodiscover = new Zend_Soap_AutoDiscover(); $autodiscover->setBindingStyle(array('style' => 'document')); $autodiscover->setOperationBodyStyle(array('use' => 'literal')); $autodiscover->setComplexTypeStrategy('Zend_Soap_Wsdl_Strategy_ArrayOfTypeSequence');

I committed these changes in trunk (commit #24718). Try it and let me know, thanks.

Fixed the Zend_Soap_Server_Proxy class with unit test (commit #24744).

With some delay (3 months...) I finally tested the proxy solution you committed, Enrico. As far as I can see, it works fine when using {{setClass()}} on {{Zend_Soap_Server}}, but when using {{setObject()}} it instantiates a new, fresh object instead of using the existing one. IMO this defeats the purpose of the {{setObject()}} method, which is to be able to pass an already instantiated and configured object.

I've prepared a patch against r25030 to address this, and to improve extensibility of the Proxy class (additional parameter to {{_preProcessArguments()}} and new {{_preProcessResult()}} method)).

Since I don't seem to have an "Add attachment" button in JIRA (permissions issue?), I'll just post it below. Could you please review it and apply it quickly, hopefully before ZF 1.12.0 gets released? :)


--- library/Zend/Soap/Server/Proxy.php  (revision 25030)
+++ library/Zend/Soap/Server/Proxy.php  (working copy)
@@ -26,27 +26,68 @@
      * @var object
      */
     protected $_classInstance;
+
     /**
-     * @var string
-     */
-    protected $_className;
-    /**
      * Constructor
      * 
-     * @param object $service 
+     * @param string|object $className name or instance of the service class to proxy
+     * @param array $classArgs arguments used to instantiate the handling class
      */
     public function  __construct($className, $classArgs = array())
     {
+        if (is_object($className)) {
+            $this->setObject($className);
+        } else if (is_string($className)) {
+            $this->setClass($className, $classArgs);
+        } else {
+            require_once 'Zend/Soap/Server/Exception.php';
+            throw new Zend_Soap_Server_Exception('Invalid className argument (' . gettype($className) . ')');
+        }
+    }
+
+    /**
+     * Set the service class to proxy.
+     * 
+     * @param string $className name of the handling class to proxy.
+     * @param array $classArgs arguments used to instantiate the handling class.
+     */
+    public function setClass($className, $classArgs = array())
+    {
+        if (!is_string($className)) {
+            require_once 'Zend/Soap/Server/Exception.php';
+            throw new Zend_Soap_Server_Exception('Invalid class argument (' . gettype($className) . ')');
+        }
+
+        if (!class_exists($className)) {
+            require_once 'Zend/Soap/Server/Exception.php';
+            throw new Zend_Soap_Server_Exception('Class "' . $className . '" does not exist');
+        }
+
         $class = new ReflectionClass($className);
         $constructor = $class->getConstructor();
-   if ($constructor === null) {
+        if ($constructor === null) {
             $this->_classInstance = $class->newInstance();
-   } else {
+        } else {
             $this->_classInstance = $class->newInstanceArgs($classArgs);
-   }
-   $this->_className = $className;
+        }
     }
+
     /**
+     * Set the service object to proxy.
+     * 
+     * @param object $object
+     */
+    public function setObject($object)
+    {
+        if (!is_object($object)) {
+            require_once 'Zend/Soap/Server/Exception.php';
+            throw new Zend_Soap_Server_Exception('Invalid object argument (' . gettype($object) . ')');
+        }
+
+        $this->_classInstance = $object;
+    }
+
+    /**
      * Proxy for the WS-I compliant call
      * 
      * @param  string $name
@@ -55,21 +96,35 @@
      */
     public function __call($name, $arguments)
     {
-        $result = call_user_func_array(array($this->_classInstance, $name), $this->_preProcessArguments($arguments));
-        return array("{$name}Result"=>$result);
+        $result = call_user_func_array(array($this->_classInstance, $name), $this->_preProcessArguments($name, $arguments));
+        return $this->_preProcessResult($name, $result);
     }
+
     /**
-     *  Pre process arguments
+     * Pre process arguments
      * 
+     * @param  string $name
      * @param  mixed $arguments
-     * @return array 
+     * @return array
      */
-    protected function _preProcessArguments($arguments)
+    protected function _preProcessArguments($name, $arguments)
     {
         if (count($arguments) == 1 && is_object($arguments[0])) {
             return get_object_vars($arguments[0]);
-   } else {
+        } else {
             return $arguments;
-   }
+        }
     }
+
+    /**
+     * Pre process result
+     * 
+     * @param  string $name
+     * @param  mixed $result
+     * @return array
+     */
+    protected function _preProcessResult($name, $result)
+    {
+        return array("{$name}Result" => $result);
+    }
 }
\ No newline at end of file