diff --git a/CHANGELOG-2.1.md b/CHANGELOG-2.1.md
index e72611d7bf78d..c656c53ab14a2 100644
--- a/CHANGELOG-2.1.md
+++ b/CHANGELOG-2.1.md
@@ -141,6 +141,7 @@ To get the diff between two versions, go to https://github.com/symfony/symfony/c
### HttpFoundation
+ * added support for streamed responses
* made Response::prepare() method the place to enforce HTTP specification
* [BC BREAK] moved management of the locale from the Session class to the Request class
* added a generic access to the PHP built-in filter mechanism: ParameterBag::filter()
diff --git a/src/Symfony/Bundle/FrameworkBundle/Controller/Controller.php b/src/Symfony/Bundle/FrameworkBundle/Controller/Controller.php
index 43ec0d1180252..24304f00c5bdf 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Controller/Controller.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Controller/Controller.php
@@ -13,6 +13,7 @@
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\DependencyInjection\ContainerAware;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Form\FormTypeInterface;
@@ -98,6 +99,32 @@ public function render($view, array $parameters = array(), Response $response =
return $this->container->get('templating')->renderResponse($view, $parameters, $response);
}
+ /**
+ * Streams a view.
+ *
+ * @param string $view The view name
+ * @param array $parameters An array of parameters to pass to the view
+ * @param StreamedResponse $response A response instance
+ *
+ * @return StreamedResponse A StreamedResponse instance
+ */
+ public function stream($view, array $parameters = array(), StreamedResponse $response = null)
+ {
+ $templating = $this->container->get('templating');
+
+ $callback = function () use ($templating, $view, $parameters) {
+ $templating->stream($view, $parameters);
+ };
+
+ if (null === $response) {
+ return new StreamedResponse($callback);
+ }
+
+ $response->setCallback($callback);
+
+ return $response;
+ }
+
/**
* Returns a NotFoundHttpException.
*
diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
index e421ab0f695e4..6e16fb51cd0b2 100644
--- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
+++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
@@ -389,6 +389,7 @@ function($v, Reference $ref) use ($container) {
$this->addClassesToCompile(array(
'Symfony\\Bundle\\FrameworkBundle\\Templating\\GlobalVariables',
'Symfony\\Bundle\\FrameworkBundle\\Templating\\EngineInterface',
+ 'Symfony\\Bundle\\FrameworkBundle\\Templating\\StreamingEngineInterface',
'Symfony\\Component\\Templating\\TemplateNameParserInterface',
'Symfony\\Component\\Templating\\TemplateNameParser',
'Symfony\\Component\\Templating\\EngineInterface',
diff --git a/src/Symfony/Bundle/FrameworkBundle/HttpKernel.php b/src/Symfony/Bundle/FrameworkBundle/HttpKernel.php
index 60313723b6a45..1c8fffca677c4 100644
--- a/src/Symfony/Bundle/FrameworkBundle/HttpKernel.php
+++ b/src/Symfony/Bundle/FrameworkBundle/HttpKernel.php
@@ -12,6 +12,7 @@
namespace Symfony\Bundle\FrameworkBundle;
use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -146,7 +147,11 @@ public function render($controller, array $options = array())
throw new \RuntimeException(sprintf('Error when rendering "%s" (Status code is %s).', $request->getUri(), $response->getStatusCode()));
}
- return $response->getContent();
+ if (!$response instanceof StreamedResponse) {
+ return $response->getContent();
+ }
+
+ $response->sendContent();
} catch (\Exception $e) {
if ($options['alt']) {
$alt = $options['alt'];
diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.xml
index b51e20fd1e42d..d34ecdf3c3727 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.xml
+++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.xml
@@ -8,6 +8,7 @@
Symfony\Bundle\FrameworkBundle\Controller\ControllerResolver
Symfony\Bundle\FrameworkBundle\Controller\ControllerNameParser
Symfony\Component\HttpKernel\EventListener\ResponseListener
+ Symfony\Component\HttpKernel\EventListener\StreamedResponseListener
Symfony\Component\HttpKernel\EventListener\LocaleListener
@@ -29,6 +30,10 @@
%kernel.charset%
+
+
+
+
%kernel.default_locale%
diff --git a/src/Symfony/Bundle/TwigBundle/TwigEngine.php b/src/Symfony/Bundle/TwigBundle/TwigEngine.php
index 283cb8dfd9e3b..2ca6c5c7be539 100644
--- a/src/Symfony/Bundle/TwigBundle/TwigEngine.php
+++ b/src/Symfony/Bundle/TwigBundle/TwigEngine.php
@@ -17,13 +17,14 @@
use Symfony\Component\Templating\TemplateNameParserInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Config\FileLocatorInterface;
+use Symfony\Component\Templating\StreamingEngineInterface;
/**
* This engine knows how to render Twig templates.
*
* @author Fabien Potencier
*/
-class TwigEngine implements EngineInterface
+class TwigEngine implements EngineInterface, StreamingEngineInterface
{
protected $environment;
protected $parser;
@@ -75,6 +76,19 @@ public function render($name, array $parameters = array())
}
}
+ /**
+ * Streams a template.
+ *
+ * @param mixed $name A template name or a TemplateReferenceInterface instance
+ * @param array $parameters An array of parameters to pass to the template
+ *
+ * @throws \RuntimeException if the template cannot be rendered
+ */
+ public function stream($name, array $parameters = array())
+ {
+ $this->load($name)->display($parameters);
+ }
+
/**
* Returns true if the template exists.
*
diff --git a/src/Symfony/Component/HttpFoundation/StreamedResponse.php b/src/Symfony/Component/HttpFoundation/StreamedResponse.php
new file mode 100644
index 0000000000000..503ccef7831d2
--- /dev/null
+++ b/src/Symfony/Component/HttpFoundation/StreamedResponse.php
@@ -0,0 +1,122 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+/**
+ * StreamedResponse represents a streamed HTTP response.
+ *
+ * A StreamedResponse uses a callback for its content.
+ *
+ * The callback should use the standard PHP functions like echo
+ * to stream the response back to the client. The flush() method
+ * can also be used if needed.
+ *
+ * @see flush()
+ *
+ * @author Fabien Potencier
+ *
+ * @api
+ */
+class StreamedResponse extends Response
+{
+ protected $callback;
+ protected $streamed;
+
+ /**
+ * Constructor.
+ *
+ * @param mixed $callback A valid PHP callback
+ * @param integer $status The response status code
+ * @param array $headers An array of response headers
+ *
+ * @api
+ */
+ public function __construct($callback = null, $status = 200, $headers = array())
+ {
+ parent::__construct(null, $status, $headers);
+
+ if (null !== $callback) {
+ $this->setCallback($callback);
+ }
+ $this->streamed = false;
+ }
+
+ /**
+ * Sets the PHP callback associated with this Response.
+ *
+ * @param mixed $callback A valid PHP callback
+ */
+ public function setCallback($callback)
+ {
+ $this->callback = $callback;
+ if (!is_callable($this->callback)) {
+ throw new \LogicException('The Response callback must be a valid PHP callable.');
+ }
+ }
+
+ /**
+ * @{inheritdoc}
+ */
+ public function prepare(Request $request)
+ {
+ if ('1.0' != $request->server->get('SERVER_PROTOCOL')) {
+ $this->setProtocolVersion('1.1');
+ $this->headers->set('Transfer-Encoding', 'chunked');
+ }
+
+ $this->headers->set('Cache-Control', 'no-cache');
+
+ parent::prepare($request);
+ }
+
+ /**
+ * @{inheritdoc}
+ *
+ * This method only sends the content once.
+ */
+ public function sendContent()
+ {
+ if ($this->streamed) {
+ return;
+ }
+
+ $this->streamed = true;
+
+ if (null === $this->callback) {
+ throw new \LogicException('The Response callback must not be null.');
+ }
+
+ call_user_func($this->callback);
+ }
+
+ /**
+ * @{inheritdoc}
+ *
+ * @throws \LogicException when the content is not null
+ */
+ public function setContent($content)
+ {
+ if (null !== $content) {
+ throw new \LogicException('The content cannot be set on a StreamedResponse instance.');
+ }
+ }
+
+ /**
+ * @{inheritdoc}
+ *
+ * @return false
+ */
+ public function getContent()
+ {
+ return false;
+ }
+}
diff --git a/src/Symfony/Component/HttpKernel/EventListener/StreamedResponseListener.php b/src/Symfony/Component/HttpKernel/EventListener/StreamedResponseListener.php
new file mode 100644
index 0000000000000..588c5fe9b1611
--- /dev/null
+++ b/src/Symfony/Component/HttpKernel/EventListener/StreamedResponseListener.php
@@ -0,0 +1,52 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpKernel\EventListener;
+
+use Symfony\Component\HttpFoundation\StreamedResponse;
+use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
+use Symfony\Component\HttpKernel\HttpKernelInterface;
+use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * StreamedResponseListener is responsible for sending the Response
+ * to the client.
+ *
+ * @author Fabien Potencier
+ */
+class StreamedResponseListener implements EventSubscriberInterface
+{
+ /**
+ * Filters the Response.
+ *
+ * @param FilterResponseEvent $event A FilterResponseEvent instance
+ */
+ public function onKernelResponse(FilterResponseEvent $event)
+ {
+ if (HttpKernelInterface::MASTER_REQUEST !== $event->getRequestType()) {
+ return;
+ }
+
+ $response = $event->getResponse();
+
+ if ($response instanceof StreamedResponse) {
+ $response->send();
+ }
+ }
+
+ static public function getSubscribedEvents()
+ {
+ return array(
+ KernelEvents::RESPONSE => array('onKernelResponse', -1024),
+ );
+ }
+}
diff --git a/src/Symfony/Component/Templating/DelegatingEngine.php b/src/Symfony/Component/Templating/DelegatingEngine.php
index db7782736af6a..889da6fc54319 100644
--- a/src/Symfony/Component/Templating/DelegatingEngine.php
+++ b/src/Symfony/Component/Templating/DelegatingEngine.php
@@ -18,7 +18,7 @@
*
* @api
*/
-class DelegatingEngine implements EngineInterface
+class DelegatingEngine implements EngineInterface, StreamingEngineInterface
{
protected $engines;
@@ -55,6 +55,26 @@ public function render($name, array $parameters = array())
return $this->getEngine($name)->render($name, $parameters);
}
+ /**
+ * Streams a template.
+ *
+ * @param mixed $name A template name or a TemplateReferenceInterface instance
+ * @param array $parameters An array of parameters to pass to the template
+ *
+ * @throws \RuntimeException if the template cannot be rendered
+ *
+ * @api
+ */
+ public function stream($name, array $parameters = array())
+ {
+ $engine = $this->getEngine($name);
+ if (!$engine instanceof StreamingEngineInterface) {
+ throw new \LogicException(sprintf('Template "%s" cannot be streamed as the engine supporting it does not implement StreamingEngineInterface.', $name));
+ }
+
+ $engine->stream($name, $parameters);
+ }
+
/**
* Returns true if the template exists.
*
diff --git a/src/Symfony/Component/Templating/PhpEngine.php b/src/Symfony/Component/Templating/PhpEngine.php
index 73be8ed3561c7..4b23ad669bb88 100644
--- a/src/Symfony/Component/Templating/PhpEngine.php
+++ b/src/Symfony/Component/Templating/PhpEngine.php
@@ -28,7 +28,7 @@
*
* @api
*/
-class PhpEngine implements EngineInterface, \ArrayAccess
+class PhpEngine implements EngineInterface, StreamingEngineInterface, \ArrayAccess
{
protected $loader;
protected $current;
@@ -107,6 +107,21 @@ public function render($name, array $parameters = array())
return $content;
}
+ /**
+ * Streams a template.
+ *
+ * @param mixed $name A template name or a TemplateReferenceInterface instance
+ * @param array $parameters An array of parameters to pass to the template
+ *
+ * @throws \RuntimeException if the template cannot be rendered
+ *
+ * @api
+ */
+ public function stream($name, array $parameters = array())
+ {
+ throw new \LogicException('The PHP engine does not support streaming.');
+ }
+
/**
* Returns true if the template exists.
*
diff --git a/src/Symfony/Component/Templating/StreamingEngineInterface.php b/src/Symfony/Component/Templating/StreamingEngineInterface.php
new file mode 100644
index 0000000000000..4b6b303bd3265
--- /dev/null
+++ b/src/Symfony/Component/Templating/StreamingEngineInterface.php
@@ -0,0 +1,32 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Templating;
+
+/**
+ * StreamingEngineInterface provides a method that knows how to stream a template.
+ *
+ * @author Fabien Potencier
+ */
+interface StreamingEngineInterface
+{
+ /**
+ * Streams a template.
+ *
+ * The implementation should output the content directly to the client.
+ *
+ * @param mixed $name A template name or a TemplateReferenceInterface instance
+ * @param array $parameters An array of parameters to pass to the template
+ *
+ * @throws \RuntimeException if the template cannot be rendered
+ */
+ function stream($name, array $parameters = array());
+}
diff --git a/tests/Symfony/Tests/Component/HttpFoundation/StreamedResponseTest.php b/tests/Symfony/Tests/Component/HttpFoundation/StreamedResponseTest.php
new file mode 100644
index 0000000000000..308a4651fb528
--- /dev/null
+++ b/tests/Symfony/Tests/Component/HttpFoundation/StreamedResponseTest.php
@@ -0,0 +1,89 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Tests\Component\HttpFoundation;
+
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+
+class StreamedResponseTest extends \PHPUnit_Framework_TestCase
+{
+ public function testConstructor()
+ {
+ $response = new StreamedResponse(function () { echo 'foo'; }, 404, array('Content-Type' => 'text/plain'));
+
+ $this->assertEquals(404, $response->getStatusCode());
+ $this->assertEquals('text/plain', $response->headers->get('Content-Type'));
+ }
+
+ public function testPrepareWith11Protocol()
+ {
+ $response = new StreamedResponse(function () { echo 'foo'; });
+ $request = Request::create('/');
+ $request->server->set('SERVER_PROTOCOL', '1.1');
+
+ $response->prepare($request);
+
+ $this->assertEquals('1.1', $response->getProtocolVersion());
+ $this->assertEquals('chunked', $response->headers->get('Transfer-Encoding'));
+ $this->assertEquals('no-cache, private', $response->headers->get('Cache-Control'));
+ }
+
+ public function testPrepareWith10Protocol()
+ {
+ $response = new StreamedResponse(function () { echo 'foo'; });
+ $request = Request::create('/');
+ $request->server->set('SERVER_PROTOCOL', '1.0');
+
+ $response->prepare($request);
+
+ $this->assertEquals('1.0', $response->getProtocolVersion());
+ $this->assertNull($response->headers->get('Transfer-Encoding'));
+ $this->assertEquals('no-cache, private', $response->headers->get('Cache-Control'));
+ }
+
+ public function testSendContent()
+ {
+ $called = 0;
+
+ $response = new StreamedResponse(function () use (&$called) { ++$called; });
+
+ $response->sendContent();
+ $this->assertEquals(1, $called);
+
+ $response->sendContent();
+ $this->assertEquals(1, $called);
+ }
+
+ /**
+ * @expectedException \LogicException
+ */
+ public function testSendContentWithNonCallable()
+ {
+ $response = new StreamedResponse('foobar');
+ $response->sendContent();
+ }
+
+ /**
+ * @expectedException \LogicException
+ */
+ public function testSetContent()
+ {
+ $response = new StreamedResponse(function () { echo 'foo'; });
+ $response->setContent('foo');
+ }
+
+ public function testGetContent()
+ {
+ $response = new StreamedResponse(function () { echo 'foo'; });
+ $this->assertFalse($response->getContent());
+ }
+}