Recently I’ve had to interact with a number of SOAP web services, and I’ve come up with some techniques to help build good SOAP web service consumers.
The two most important goals when building a SOAP consumer is for the code to be maintainable and testable.
Dark Beginnings
A natural first approach is to write one class that handles everything necessary to interact with the web service:
<?php
namespace App;
use SoapClient;
class Consumer
{
protected $client;
public function __construct($wsdl, $username, $password)
{
$this->client = new SoapClient($wsdl, [
'login' => $this->username,
'password' => $this->password
]);
}
public function getBooks()
{
$response = $this->client->getBooks();
// Transform the response as appropriate...
return $response;
}
}
What we’ve written above is difficult to test because we’re constructing the SoapClient
ourselves. Testing any methods on the consumer would mean making a real call to the web service.
We can do better:
<?php
namespace App;
class Consumer
{
protected $client;
public function __construct($client)
{
$this->client = $client
}
// ..
}
By having the consumer declare outright what it requires to work (an instance of a client) we’re now able to construct the consumer with mock instances of the SoapClient
where we can fake real calls to the web service.
Writing a Test
Now that we have a way to inject a client into our consumer, let’s use Mockery to fake a web service call:
<?php
namespace Tests\Unit\WebServices\Consumer\Methods;
use App\Consumer;
use Mockery as m;
use Tests\TestCase;
class GetBooksTest extends TestCase
{
/** @test */
function it_gets_books()
{
// Mock the client to return our XML...
$client = m::mock()
->shouldReceive('getBooks')
->once()
->andReturn(simplexml_load_string($this->getXml()))
->getMock();
// Inject our mock SoapClient into the consumer
// and make the call that we're testing...
$response = (new Consumer($client))->getBooks();
// Assert that the response is what we would expect...
$this->assertEquals([
[
'title' => 'The Alchemist',
], [
'title' => 'Veronica Decides To Die',
], [
'title' => 'The Second Machine Age',
],
], $response);
}
private function getXml()
{
return <<<XML
<GetBooksResponse>
<Books>
<Book>
<Title>The Alchemist</Title>
</Book>
<Book>
<Title>Veronica Decides To Die</Title>
</Book>
<Book>
<Title>The Second Machine Age</Title>
</Book>
</Books>
<GetBooksResponse>
XML;
}
}
Since PHP’s SoapClient
returns a SimpleXml
object from a web service method call, that’s what we’ll have our mock object return too – except we’ll use our predefined XML snippet in order to control our testing environment.
We’re now free to test the response as we sit fit!
Avoiding a 4000 Line Long Consumer Class
As you write more and more code to consume the methods of the web service, your class will quickly grow long. This will happen especially quickly if there is any complex logic associated with formatting web service method requests and responses.
The approach I use to keep classes short is to write one class per web service method. Here’s how:
<?php
namespace App;
use Exception;
class Consumer
{
protected $client;
public function __construct($client)
{
$this->client = $client
}
public function __call($method, $parameters)
{
if (! class_exists($class = $this->getClassNameFromMethod($method))) {
throw new Exception("Method {$method} does not exist");
}
$instance = new $class($this->client);
// Delegate the handling of this method call to the appropriate class
return call_user_func_array([$instance, 'execute'], $parameters);
}
/**
* Get class name that handles execution of this method
*
* @param $method
* @return string
*/
private function getClassNameFromMethod($method)
{
return 'App\\Methods\\' . ucwords($method);
}
}
The refactored consumer class now looks for a class in the App\Methods
\* namespace with the same name as the method being called. If found, it will create an instance of the class, and delegate to it.
In our example, a call to $consumer->getBooks()
would internally be routed to another class called App\Methods\GetBooks
.
No matter how many methods we need to consume, our consumer class will never get any bigger!
Bonus: our consumer class now conforms to the Open and Closed principle.
Here’s what our App\Methods\GetBooks
class looks like:
<?php
namespace App\Methods;
class GetBooks
{
protected $client;
public function __construct($client)
{
$this->client = $client;
}
public function execute()
{
$response = $this->client->getBooks();
// Transform the response as appropriate...
return $response;
}
}
Caching Calls
A common optimization technique is to cache web service calls. With a small tweak to our consumer, we can allow our method classes to be responsible for their own caching:
<?php
namespace App;
use Exception;
use App\Cacheable;
class Consumer
{
protected $client;
public function __construct(client)
{
$this->client = $client
}
public function __call($method, $parameters)
{
if (! class_exists($class = $this->getClassNameFromMethod($method))) {
throw new Exception("Method {$method} does not exist");
}
$instance = new $class($this->client);
if ($instance instanceof Cacheable) {
return $instance->cache($parameters);
}
// Delegate the handling of this method call to the appropriate class
return call_user_func_array([$instance, 'execute'], $parameters);
}
/**
* Get class name that handles execution of this method
*
* @param $method
* @return string
*/
private function getClassNameFromMethod($method)
{
return 'App\\Methods\\' . ucwords($method);
}
}
Whenever a method class implements the App\Cacheable
interface, the cache
method will be called instead. If using Laravel, this could look like the below:
public function cache($parameters)
{
return app('cache')->remember('Ws.GetBooks', 10, function () use ($parameters) {
return call_user_func_array([$this, 'execute'], $parameters);
});
}
Logging Calls
Debugging will be infinitely easier if all your web service calls are logged. Since we’re injecting the SoapClient
into our consumer, we can write a simple decorator to log all web service calls:
<?php
namespace App;
use SoapClient;
use Psr\Log\LoggerInterface;
class SoapClientLogger
{
protected $client;
protected $logger;
public function __construct(SoapClient $client, LoggerInterface $logger)
{
$this->client = $client;
$this->logger = $logger;
}
public function __call($method, $parameters)
{
$response = call_user_func_array([$this->client, $method], $parameters);
$this->logger->info("Request: {$method}. " . $this->client->__getLastRequest());
$this->logger->info("Response:", (array) $response);
return $response;
}
}
For the request to be logged correctly, we’ll need to enable tracing when configuring our SoapClient:
$consumer = new Consumer(new SoapClientLogger(new SoapClient(config('services.web_service.wsdl'), [
'login' => config('services.web_service.login'),
'password' => config('services.web_service.password'),
'trace' => true,
]), $this->getLogger()));
Of course, we can configure Laravel’s Container to correctly build our consumer class whenever we request it, by writing the following code in a service provider:
$this->app->bind(Consumer::class, function ($app) {
return new Consumer(new SoapClientLogger(new SoapClient(config('services.web_service.wsdl'), [
'login' => config('services.web_service.login'),
'password' => config('services.web_service.password'),
'trace' => true,
]), $this->getLogger()));
});
End
Dealing with SOAP web services can be a messy business. Use and improve upon the techniques written here to make the process a little bit more pleasant.
Happy coding!
Do you have a repository to see the complete SOAP Web Service example?