Getting SOAPy with ZF2


Taking a little break from my learning application to really just jump in with both feet into our current first Zend application. As we're getting near the deadline for completion, I was tasked with creating the SOAP stuff that another app will be accessing to pull data from our application. Before anyone chimes in "you should do Restful API", the other app has a higher priority and we don't get to dictate what it will do. So SOAP it is.

The documentation for SOAP in Zend is particularly sucky.  The official docs neglect a lot of the really basic stuff, like where stuff goes and how, and because it seems like most folks are moving to Restful APIs, the majority of articles I found via Google covered ZF1 not ZF2. It took most of the morning to just gather enough bits and pieces to get together a working module. Once it was working and confirmed to be good to go, I decided I'm posting about this, cause I doubt I'm the only one still dealing with SOAP 🙂

For simplicity sake, I made my SOAP a separate module, called Soap.  It only has fives files, and an excessive number of folders.

module
	Soap
		config
			-- module.config.php
		src
			Soap
				-- SoapController.php
				-- SoapServe.php
		view
			soap
				soap
					-- index.phtml
		-- Module.php

Let's go through the files one by one.  First, Module.php, which is the standard fare.

<?php
	namespace Soap;
	
	use Zend\ModuleManager\Feature\AutoloaderProviderInterface;
 	use Zend\ModuleManager\Feature\ConfigProviderInterface;
	
	class Module implements AutoloaderProviderInterface, ConfigProviderInterface {
		public function getAutoloaderConfig() {
			return array(
				'Zend\Loader\StandardAutoloader' => array(
					'namespaces' => array(
						__NAMESPACE__ => __DIR__ . '/src/' . __NAMESPACE__,
					),
				),
			);
		}
		
		public function getConfig() {
			return include __DIR__ . '/config/module.config.php';
		}
	}
?>

Then the module.config.php to set up our route for the SOAP requests, our controller, and our view manager. Yes, the latter is needed.

<?php
	return array(
		'router' => array(
			'routes' => array(
				// Route for SOAP requests
				'SoapServe' => array(
					'type' => 'Literal',
					'options' => array(
						'route' => '/SoapServe',
						'defaults' => array(
							'controller' => 'Soap\Controller\Soap',
							'action' => 'index',
						),
					),
					'may_terminate' => true,
					// so that it will accept the ?wsdl if requested - else it redirects as a bad route
					'child_routes' => array(
						'default' => array(
							'type'	=> 'Segment',
							'options' => array(
								'route'	=> '/[:wsdl]',
								'constraints' => array(
									'controller' => '[a-zA-Z][a-zA-Z0-9_-]*',
									'action'	 => '[a-zA-Z][a-zA-Z0-9_-]*',
								),
								'defaults' => array(
								),
							),
						),
					),
				),		   
			),
		),
		'controllers' => array(
			'invokables' => array(
				'Soap\Controller\Soap' => 'Soap\SoapController',
			),
		),
		'view_manager' => array(
			'template_path_stack' => array(
				__DIR__ . '/../view',
			),
		),
	);
?>

Now into the meat, our src.  Here we have the controller and our actual service.

<?php
	namespace Soap;
	
	use Zend\Mvc\Controller\AbstractActionController;
	use Zend\Soap\Client;
	use Zend\Soap\Server;
	use Zend\Soap\AutoDiscover;
	use Zend\View\Model\ViewModel;
	use Soap\SoapServe;
	
	class SoapController extends AbstractActionController {
		private $_URI;
		private $_WSDL_URI;
		
		public function indexAction() {
			// set up service locator for SoapServe class
			$sm = $this->getServiceLocator();
			
			// get the current URL so it works in dev, staging, production, etc
			$this->_URI = 'http' . (isset($_SERVER['HTTPS']) ? 's' : '') . '://' . "{$_SERVER['HTTP_HOST']}/SoapServe";
			$this->_WSDL_URI = 'http' . (isset($_SERVER['HTTPS']) ? 's' : '') . '://' . "{$_SERVER['HTTP_HOST']}/SoapServe?wsdl";
			
			// is this a request for the wsdl or a SOAP request?
			if (isset($_GET['wsdl'])) {
				$this->handleWSDL($sm);
			} else {
				$this->handleSOAP($sm);
			}
			
			// this is required to strip out the layout, otherwise not nice results!
			$result = new ViewModel();
			$result->setTerminal(true);
			
			return $result;
			
		}
		// if we have a wsdl request, use AutoDiscover to create the file and serve it
		private function handleWSDL($sm) {
			$autodiscover = new AutoDiscover();
			$autodiscover->setClass(new SoapServe($sm))
				->setUri($this->_URI);
			
			$wsdl = $autodiscover->generate();
			echo $wsdl->toXml();
			
			$wsdl->dump("public/SoapServe.wsdl");
			$dom = $wsdl->toDomDocument();
		}
		
		// is this a SOAP request? send it on to our SoapServe class
		private function handleSOAP($sm) {
			$soap = new Server(
				NULL,
				array(
					'wsdl' => $this->_WSDL_URI
				)
			);
			$soap->setClass(new SoapServe($sm));
			$soap->handle();
		}
	}
?>

The controller's primary job here is to receive our request, snag the service layer for our class, and either call the function to generate a wsdl if we have a wsdl request or to process the actual SOAP request.  Now, I'm not positive, but since my code is writing the wsdl to file, I could likely use an if to only do the auto discover again if the wsdl is X days old or doesn't exist.  Or just not write it to file at all, since it processes quickly.

The processing of the requests happens in our SoapServe class.  For this purpose, we just have two functions – one to get a list of county names and one to get a requested county's scheduled holidays for the requested year.  As you can see, most of the processing is done in our DAOs which are in the main module of the app.  They run the appropriate queries and pass back a result set.  Then I loop through and put them in an array to pass back to our requester.

<?php
	namespace Soap;

	class SoapServe {
		public $sm;
		
		// set our service locator so we can access the DAOs elsewhere.
		public function __construct($sm) {
			$this->sm = $sm;		
		}
		
		/**
		* Retrieves list of all county offices in Extension
		*
		* @param int $FiscalYear four digit year, must be within 2 years of current year
		* @return array gives back blank Adlock, X for workstation, and county office names (minus County Office) if successful; on fail returns error message
		*/
		public function getCountyNames($FiscalYear) {
			$minYear = date("Y") - 2;
			$maxYear = date("Y") + 2;

			// require the fiscal year be 4 digits and within two years of the current year
			if (strlen($FiscalYear) == 4
				&& $FiscalYear >= $minYear
				&& $FiscalYear <= $maxYear
				) {
				
				// pass the request off to our DAO
				$CountiesDAO = $this->sm->get('CountiesDAO');
				$counties = $CountiesDAO->fetchCounties();
			
				$aCounties = array();
				foreach ($counties as $county) {
					array_push($aCounties, array(
					'CountyName' => $county["countyname"],
					));
				}
			
				return $aCounties;
			}
			// bad year? give them an error
			else {
				return array(
					'ErrorCode' => 301,
					'ErrorMessage' => 'Invalid year passed, can only be 2 +/- of current year'
				);
			}
		}
		
		/**
		* Retrieves list of holidays for requested county
		*
		* @param int $FiscalYear four digit year, must be within 2 years of current year
		* @param string $CountyName name of county to pull - does not need to include the word County
		* @return array gives back FiscalYear, Date, HolidayDescription, and Hours if successful; on fail returns error message
		*/
		public function getHolidayDates($FiscalYear, $CountyName) {
			$minYear = date("Y") - 2;
			$maxYear = date("Y") + 2;

			if (strlen($FiscalYear) == 4
				&& $FiscalYear >= $minYear
				&& $FiscalYear <= $maxYear
				) {
				$CountySchedulesDAO = $this->sm->get('CountySchedulesDAO');
				$holidays = $CountySchedulesDAO->getHolidays($FiscalYear, $CountyName);
				
				$aHolidays = array();
	
				// yes, we have to hard code the number of hours...
				foreach ($holidays as $thisHoliday) {
					array_push($aHolidays, array(
						'FiscalYear' => $FiscalYear,
						'HolidayDate' => $thisHoliday['holidaydate'],
						'HolidayName' => $thisHoliday['holidayname'],
						'Hours' => 8
					));
				}
			
				return $aHolidays;
			}
			else {
				return array(
					'ErrorCode' => 301,
					'ErrorMessage' => 'Invalid year passed, can only be 2 +/- of current year'
				);
			}
		} 
	}

?>

And finally our "view". The index.phtml has nothing in it, but without Zend kept throwing a hissy fit.  None of the tricks I found for trying to disable the rendering worked, so if you know a way to tell Zend to quite looking for an index, by all means let me know.

<?php // blah blah blah ?>

And that's it.  I used the free version of SoapUI to test the soap requests and both functions gave us exactly what we expected back out. I could (and probably will) put in some more error checking/handling for the final code, but it's a solid start to what we need and in this case, the SOAP is purely a data return based on variables.