I know what you're thinking, because it's the same thing I've been thinking for a while.
Creating a soap service?
Hey, 2008 called, they want their web service back.
The reality of the situation is that soap services are quite common, even with new platforms, and in my capacity as a back end developer I come across them all the time. Many well known platforms use soap as a standard way of communication. I have recently created a soap service to integrate with outbound messages from Salesforce, but have also created soap services for billing platforms and subscription gateways.
What is soap?
By definition, a soap service is a web service that uses standard GET and POST HTTP requests to send and receive XML data, the structure of which is defined in a Web Service Description Language (WSDL) file. The WSDL file (commonly called a "wizdle" file) defines all the types of things that the soap service can do along with the data types needed. This essentially means that soap is self documenting in that the WSDL file defines the service. Soap is also able to translate complex objects from one platform to another. Soap originally stood for Simple Object Access Protocol, but this acronym has not been used since version 1.2 of the standard. Version 1.2 of the soap standard was originally released in 2007, but is still in use to this day.
That last paragraph might make it seem like I enjoy working with soap services. Don't get me wrong, I hate soap. It might have many conveniences in certain environments, but in the PHP world soap it is difficult to set up, a pain to debug and almost impossible to handle errors correctly. The soap acronym was probably dropped because it's not simple, it doesn't have to map to objects, it doesn't necessarily have anything to do with access and, well, it is a protocol so I'll give it that.
RESTful services are now baked into Drupal 8, but soap isn't and there appears to be no drive by the Drupal community to create a module that provides soap services. Not only that but in my experience soap services tend to do quite custom and aren't used much to update content so creating a generic soap service module is quite a challenge.
When I started building sites in Drupal 8 I needed to find a way of building a soap service. Many of the projects I've worked on recently require a soap service to be part of their setup so that they can accept data from a third party system so it was essential that I built something I could potentially adapt. To make things a little easier the soap service I created wraps the PHP SoapServer class.
Note that what I detail here is a way to get started with creating a soap service, but there will absolutely be a lot of business logic that you will need to figure out on your own.
Routing And Controller Setup
The first thing we need to do is create a route and a controller so that we can intercept our soap request and do something with it. The route we create in our *.routing.yml module isn't too complex, but has some things that you might not see often. The single argument we accept is the type of endpoint being used. This allows us to have a single soap controller that can handle multiple endpoints.
1 2 3 4 5 6 7 8 9 | access_soap_module.soap_server: path: '/services/access_soap_module/soap/{endpoint}' defaults: _controller: '\Drupal\access_soap_module\Controller\SoapController::soap' requirements: _access: 'TRUE' _method: 'GET|POST' options: no_cache: 'TRUE' |
The first thing that stands out is the _method requirement. This 'GET|POST' option essentially means that the controller will respond to both GET and POST requests. We also set a no_cache option to ensure that the endpoint is not cached.
Note that this route is also open to everyone to access. This is intentional as the incoming request will probably be from a service that won't first authenticate. It is up to the soap controller to ensure that the service has access to the endpoint.
The controller is the brains of our soap service. It will accept and respond to incoming requests in the following way.
- GET requests should respond with a WSDL file definition document.
- POST requests should instantiate the PHP SoapServer object, set the custom class that needs to be run and then handle the request. It should always respond to a soap request with XML, which is what a soap client will expect.
Because everything we want to know about our incoming HTTP request is in the request object we use the Drupal 8 dependency injection system to automatically inject the request into our controller. The soap() method should then The bare bones of what we need should look something like this.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | <?php namespace Drupal\access_soap_module\Controller; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\RequestStack; use Drupal\Core\Controller\ControllerBase; class SoapController extends ControllerBase { /** * Request var. * * @var \Symfony\Component\HttpFoundation\RequestStack */ public $request; /** * SoapController constructor. * * @param \Symfony\Component\HttpFoundation\RequestStack $request * Request stack. */ public function __construct(RequestStack $request) { $this->request = $request; } /** * {@inheritdoc} */ public static function create(ContainerInterface $container) { return new static( $container->get('request_stack') ); } /** * Soap method. * * @param string $endpoint * Endpoint url as a string. * * @return bool|Response * A response object or FALSE on failure. */ public function soap($endpoint) { // soap logic goes here. } } |
The soap() method we create basically splits the code execution into GET and POST requests and either returns a response object or throws an exception.
The GET part of this code flow is all concerned about generating a WSDL file. One of the biggest pains in getting soap services working is creating a WSDL file. WSDL files are difficult to create, not easy to understand and can fundamentally break both soap client and soap service if a single thing is changed or is incorrect. I could probably write another post on just WSDL files alone (a thrilling prospect!!) but luckily some providers (e.g. Salesforce) will allow you to create a WSDL file that you just drop in and use. I have also read that .NET provides the facility to generate WSDL files from existing objects so that probably the reason why soap is popular in the .NET world.
A GET method triggers the handleGetRequest() method which is responsible for returning a renderable array of data. I won't cover here how you render your WSDL file in your GET response as that's not too important. The important bit here is that we create a new Response object so the default Drupal template is not used. This allows the WSDL XML to be returned as it is, but we also add a header to say that we are returning XML.
One thing to be careful of when rendering your WSDL is that Drupal's twig theme debug output will cause the WSDL file to be filled with non-standard XML. This will cause soap clients connecting to the service and the service itself to fail.
The POST request is sent off to a method called handleSoapRequest() where we use the information in the request to run the soap command. The returned output is then also sent back in the same way as the GET method was.
Here is the soap() method in full.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | public function soap($endpoint) { // Get the Symfony request component so that we can adapt the page request // accordingly. $request = $this->request->getCurrentRequest(); // Respond appropriately to the different HTTP verbs. switch ($request->getMethod()) { case 'GET': // This is a get request, so we handle it by returning a WSDL file. $wsdlFileRender = $this->handleGetRequest($endpoint, $request); if ($wsdlFileRender == FALSE) { // If the WSDL file was not returned then we issue a 404. throw new NotFoundHttpException(); } // Render the WSDL file. $wsdlFileOutput = \Drupal::service('renderer')->render($wsdlFileRender); // Return the WSDL file as output. $response = new Response($wsdlFileOutput); $response->headers->set('Content-type', 'application/xml; charset=utf-8'); return $response; case 'POST': // Handle SOAP Request. $result = $this->handleSoapRequest($endpoint, $request); if ($result == FALSE) { // False should only be returned via a non-existent endpoint, // so we return a 404. throw new NotFoundHttpException(); } // Return the response from the SOAP request. $response = new Response($result); $response->headers->set('Content-type', 'application/xml; charset=utf-8'); return $response; default: // Not a GET or a POST request, return a 404. throw new NotFoundHttpException(); } } |
Note that throwing an exception in the POST request can cause difficulties as it will return HTML to the soap client. So we only do it here as a last resort. I'm sure there are other ways to respond to this sort of error but the chances of a soap client trying to get to a non-existent soap endpoint should be pretty low so it's nothing I am too worried about.
To respond to the soap request we use the handleSoapRequest() method. This method will instantiate the PHP SoapServer, set the correct class (in our case MySoapClass) and then call the handle() method of the SoapServer object. As we want to send back the response in a controlled way we use some simple output buffering to capture the output from the handle() method and then send it back as a string. Without doing this handle() would simply print out the XML, which we don't want to do. The MySoapClass class is where the actual business logic happens and is responsible for whatever it needs to be responsible for (more on that class later). By providing the fully qualified namespace of the class we can still use class autoloading to find the right class so we don't need to explicitly require it.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | public function handleSoapRequest($endpoint, $request) { // Construct the WSDL file location. $wsdlUri = \Drupal::request()->getSchemeAndHttpHost() . '/services/access_soap_module/soap/mysoapendpoint?wsdl=true'; $soapClass ='Drupal\access_soap_module\Soap\MySoapClass'; try { // Create some options for the SoapServer. $serverOptions = [ 'encoding' => 'UTF-8', 'wsdl_cache_enabled' => 1, 'wsdl_cache' => WSDL_CACHE_DISK, 'wsdl_cache_ttl' => 604800, 'wsdl_cache_limit' => 10, 'send_errors' => FALSE, ]; // Instantiate the SoapServer. $soapServer = new \SoapServer(wsdlUri, $serverOptions); $soapServer->setClass($soapClass); // Turn output buffering on. ob_start(); // Handle the SOAP request. $soapServer->handle(); // Get the buffers contents. $result = ob_get_contents(); // Removes topmost output buffer. ob_end_clean(); // Send back the result. return $result; } catch (\Exception $e) { // An error happened so we log it. \Drupal::logger('access_soap_module')->error('soap error ' . $e->getMessage()); // Then return a SoapFault object as the result. $soap_fault = new \SoapFault($e->getMessage(), $e->getCode(), NULL, $e->getErrorDetail()); return $soap_fault; } } |
WSDL vs Non-WSDL Mode
Before getting onto the creation of MySoapClass I want to talk about how the SoapServer class works. What is currently set up in the above example is the SoapServer class in what's known as WSDL mode. Here is what happens with SoapServer in WSDL mode.
- The SoapServer object is instantiated.
- This then makes a request to the WSDL file location.
- The WSDL file is rendered and returned by our Drupal SoapController. The WSDL file is then cached locally by the SoapServer object so that it doesn't need to make this request every time.
- SoapServer will then use the WSDL file to figure out what to do with the data of incoming requests. It will also provide some validation on the request to make sure that it conforms to the WSDL spec.
At least, that's what I think is going on here. As I said before soap is on the way out so there is so little documentation on exactly this subject that I have just used the class and inspected it's process flow to see what it is doing.
The keen eyed among you may have noticed that we only generate our WSDL file definition via a GET request, which means that the SoapServer needs to make a HTTP request to itself in order to get the WSDL file. This is why we add a bunch of caching options to the server setup as we need to prevent SoapServer making this request every time it gets a request. We could potentially write the WSDL file to disk and ask for this file but we would need to implement more code to ensure that the files always existed.
The non-WSDL mode is triggered by passing NULL to the SoapServer constructor (i.e. instead of the WSDL file location), which means that when the SoapServer object is instantiated we have to use addFunction() or setClass() with additional arguments to explicitly set what endpoints are available. This means that all of our soap calls endpoints need to be explicitly set in code before we get to our business logic.
The upshot of using WSDL mode is that if we need to change anything we just need to change the WSDL file and then tweak the business logic to follow. The SoapServer will then figure out what data needs to be sent to your business logic and what sort of data should be returned. This allows you to see any mistakes you have made in your WSDL file when testing it. The downside is that you have to write a WSDL file and accept that the SoapServer object will make a request for it.
The obvious question here is to use WSDL or non-WSDL mode when creating soap services. After some experience creating soap services I would say that there are pros and cons to each. If you have spent time creating a WSDL file then WSDL mode is a good way of ensuring your WSDL file is correct. WSDL mode does have a few problems, however, as it makes local development slightly harder if you are using HTTPS and can double the load to your server if you get the WSDL cache system wrong. Life isn't easier in non-WSDL mode though as you will need to write a lot more code just to get things working. One important thing to remember is that once you have picked your path it's quite difficult to change it as SoapServer can end up sending different data types to your business logic classes.
The Business Logic
Finally, with all that in place we can move onto where the actual work happens. The MySoapClass class, this class doesn't do much, but is responsible for taking the soap request and pushing it to the correct method in this object. This is done via using the magic method __call() in order to allow the class to respond with a SoapFault object instead of throwing a fatal error when no appropriate method is found. In order to send the soap request parameters to the method in question we need to use array_pop(). This is a side effect of using __call() and not something that SoapServer does.
Assuming that the mysoapmethod soap method is used, the __call() method then calls the mySoapMethod() method and passes the arguments passed to the soap call to it. The one good thing about soap is that if we have a WSDL file then we don't need to do much data type validation. The arguments arriving at mySoapMethod() will contain the data with the correct data types intact. You will obviously need to do validation to ensure that everything else is ok, but at least an integer will always be an integer.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <?php namespace Drupal\access_soap_module\Soap; class MySoapClass { public function __call($method_name, array $args) { try { switch ($method_name) { case 'mysoapmethod': return $this->mySoapMethod(array_pop($arg)); break; } } catch (\Exception $e) { // Something went wrong, so return a SoapFault. return new \SoapFault(0, $e->getMessage(), NULL); } } public function mySoapMethod($args) { $response = []; // Do something. return $response; } } |
What we return from the MySoapMethod is a PHP array representation of our soap response. Salesforce outbound messages expect the following kind of response.
1 2 3 4 5 6 7 8 | <?xml version="1.0" encoding="UTF-8"?> <SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="http://soap.sforce.com/2005/09/outbound"> <SOAP-ENV:Body> <ns1:notificationsResponse> <ns1:Ack>true</ns1:Ack> </ns1:notificationsResponse> </SOAP-ENV:Body> </SOAP-ENV:Envelope> |
The SoapServer takes care of the other data around the soap payload, so our method should return the array below.
1 | return array('Ack' => FALSE); |
That's about it. Potential future things to think about might be providing for a version number for our soap API. We can easily do this by adding another parameter to our route so that we can segment versions of our WSDL files and soap classes. That hasn't been a factor in my work up until this point, but it can easily be done with the above tools.