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()); + } +}