1<?php
2/**
3 * Zend Framework (http://framework.zend.com/)
4 *
5 * @link      http://github.com/zendframework/zf2 for the canonical source repository
6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
7 * @license   http://framework.zend.com/license/new-bsd New BSD License
8 */
9
10namespace Zend\Mvc\Controller\Plugin;
11
12use Zend\EventManager\SharedEventManagerInterface as SharedEvents;
13use Zend\Mvc\Controller\ControllerManager;
14use Zend\Mvc\Exception;
15use Zend\Mvc\InjectApplicationEventInterface;
16use Zend\Mvc\MvcEvent;
17use Zend\Mvc\Router\RouteMatch;
18
19class Forward extends AbstractPlugin
20{
21    /**
22     * @var ControllerManager
23     */
24    protected $controllers;
25
26    /**
27     * @var MvcEvent
28     */
29    protected $event;
30
31    /**
32     * @var int
33     */
34    protected $maxNestedForwards = 10;
35
36    /**
37     * @var int
38     */
39    protected $numNestedForwards = 0;
40
41    /**
42     * @var array[]|null
43     */
44    protected $listenersToDetach = null;
45
46    /**
47     * @param ControllerManager $controllers
48     */
49    public function __construct(ControllerManager $controllers)
50    {
51        $this->controllers = $controllers;
52    }
53
54    /**
55     * Set maximum number of nested forwards allowed
56     *
57     * @param  int $maxNestedForwards
58     * @return self
59     */
60    public function setMaxNestedForwards($maxNestedForwards)
61    {
62        $this->maxNestedForwards = (int) $maxNestedForwards;
63
64        return $this;
65    }
66
67    /**
68     * Get information on listeners that need to be detached before dispatching.
69     *
70     * Each entry in the array contains three keys:
71     *
72     * id (identifier for event-emitting component),
73     * event (the hooked event)
74     * and class (the class of listener that should be detached).
75     *
76     * @return array
77     */
78    public function getListenersToDetach()
79    {
80        // If a blacklist has not been explicitly set, return the default:
81        if (null === $this->listenersToDetach) {
82            // We need to detach the InjectViewModelListener to prevent templates
83            // from getting attached to the ViewModel twice when a calling action
84            // returns the output generated by a forwarded action.
85            $this->listenersToDetach = array(array(
86                'id'    => 'Zend\Stdlib\DispatchableInterface',
87                'event' => MvcEvent::EVENT_DISPATCH,
88                'class' => 'Zend\Mvc\View\Http\InjectViewModelListener',
89            ));
90        }
91        return $this->listenersToDetach;
92    }
93
94    /**
95     * Set information on listeners that need to be detached before dispatching.
96     *
97     * @param  array $listeners Listener information; see getListenersToDetach() for details on format.
98     *
99     * @return self
100     */
101    public function setListenersToDetach($listeners)
102    {
103        $this->listenersToDetach = $listeners;
104
105        return $this;
106    }
107
108    /**
109     * Dispatch another controller
110     *
111     * @param  string $name Controller name; either a class name or an alias used in the controller manager
112     * @param  null|array $params Parameters with which to seed a custom RouteMatch object for the new controller
113     * @return mixed
114     * @throws Exception\DomainException if composed controller does not define InjectApplicationEventInterface
115     *         or Locator aware; or if the discovered controller is not dispatchable
116     */
117    public function dispatch($name, array $params = null)
118    {
119        $event   = clone($this->getEvent());
120
121        $controller = $this->controllers->get($name);
122        if ($controller instanceof InjectApplicationEventInterface) {
123            $controller->setEvent($event);
124        }
125
126        // Allow passing parameters to seed the RouteMatch with & copy matched route name
127        if ($params !== null) {
128            $routeMatch = new RouteMatch($params);
129            $routeMatch->setMatchedRouteName($event->getRouteMatch()->getMatchedRouteName());
130            $event->setRouteMatch($routeMatch);
131        }
132
133        if ($this->numNestedForwards > $this->maxNestedForwards) {
134            throw new Exception\DomainException("Circular forwarding detected: greater than $this->maxNestedForwards nested forwards");
135        }
136        $this->numNestedForwards++;
137
138        // Detach listeners that may cause problems during dispatch:
139        $sharedEvents = $event->getApplication()->getEventManager()->getSharedManager();
140        $listeners = $this->detachProblemListeners($sharedEvents);
141
142        $return = $controller->dispatch($event->getRequest(), $event->getResponse());
143
144        // If we detached any listeners, reattach them now:
145        $this->reattachProblemListeners($sharedEvents, $listeners);
146
147        $this->numNestedForwards--;
148
149        return $return;
150    }
151
152    /**
153     * Detach problem listeners specified by getListenersToDetach() and return an array of information that will
154     * allow them to be reattached.
155     *
156     * @param  SharedEvents $sharedEvents Shared event manager
157     * @return array
158     */
159    protected function detachProblemListeners(SharedEvents $sharedEvents)
160    {
161        // Convert the problem list from two-dimensional array to more convenient id => event => class format:
162        $formattedProblems = array();
163        foreach ($this->getListenersToDetach() as $current) {
164            if (!isset($formattedProblems[$current['id']])) {
165                $formattedProblems[$current['id']] = array();
166            }
167            if (!isset($formattedProblems[$current['id']][$current['event']])) {
168                $formattedProblems[$current['id']][$current['event']] = array();
169            }
170            $formattedProblems[$current['id']][$current['event']][] = $current['class'];
171        }
172
173        // Loop through the class blacklist, detaching problem events and remembering their CallbackHandlers
174        // for future reference:
175        $results = array();
176        foreach ($formattedProblems as $id => $eventArray) {
177            $results[$id] = array();
178            foreach ($eventArray as $eventName => $classArray) {
179                $results[$id][$eventName] = array();
180                $events = $sharedEvents->getListeners($id, $eventName);
181                foreach ($events as $currentEvent) {
182                    $currentCallback = $currentEvent->getCallback();
183
184                    // If we have an array, grab the object
185                    if (is_array($currentCallback)) {
186                        $currentCallback = array_shift($currentCallback);
187                    }
188
189                    // This routine is only valid for object callbacks
190                    if (!is_object($currentCallback)) {
191                        continue;
192                    }
193
194                    foreach ($classArray as $class) {
195                        if ($currentCallback instanceof $class) {
196                            $sharedEvents->detach($id, $currentEvent);
197                            $results[$id][$eventName][] = $currentEvent;
198                        }
199                    }
200                }
201            }
202        }
203
204        return $results;
205    }
206
207    /**
208     * Reattach all problem listeners detached by detachProblemListeners(), if any.
209     *
210     * @param  SharedEvents $sharedEvents Shared event manager
211     * @param  array        $listeners    Output of detachProblemListeners()
212     * @return void
213     */
214    protected function reattachProblemListeners(SharedEvents $sharedEvents, array $listeners)
215    {
216        foreach ($listeners as $id => $eventArray) {
217            foreach ($eventArray as $eventName => $callbacks) {
218                foreach ($callbacks as $current) {
219                    $sharedEvents->attach($id, $eventName, $current->getCallback(), $current->getMetadatum('priority'));
220                }
221            }
222        }
223    }
224
225    /**
226     * Get the event
227     *
228     * @return MvcEvent
229     * @throws Exception\DomainException if unable to find event
230     */
231    protected function getEvent()
232    {
233        if ($this->event) {
234            return $this->event;
235        }
236
237        $controller = $this->getController();
238        if (!$controller instanceof InjectApplicationEventInterface) {
239            throw new Exception\DomainException('Forward plugin requires a controller that implements InjectApplicationEventInterface');
240        }
241
242        $event = $controller->getEvent();
243        if (!$event instanceof MvcEvent) {
244            $params = array();
245            if ($event) {
246                $params = $event->getParams();
247            }
248            $event  = new MvcEvent();
249            $event->setParams($params);
250        }
251        $this->event = $event;
252
253        return $this->event;
254    }
255}
256