Browse Source

Add a simpler response emitter.

This new ResponseEmitter offers some improved ergonomics. It no longer:

* Throws an exception when headers have been sent.
* Truncates content when debug output has been generated in the
  controller.

It also uses setcookie() which lets us remove the shims we had to apply
to restore behavior of ext/session.

Refs #9472
Mark Story 9 years ago
parent
commit
7ebc2614fd

+ 274 - 0
src/Http/ResponseEmitter.php

@@ -0,0 +1,274 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         3.3.5
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ *
+ * Parts of this file are derived from Zend-Diactoros
+ *
+ * @copyright Copyright (c) 2015-2016 Zend Technologies USA Inc. (http://www.zend.com)
+ * @license   https://github.com/zendframework/zend-diactoros/blob/master/LICENSE.md New BSD License
+ */
+namespace Cake\Http;
+
+use Cake\Core\Configure;
+use Cake\Log\Log;
+use Psr\Http\Message\ResponseInterface;
+use Zend\Diactoros\RelativeStream;
+use Zend\Diactoros\Response\EmitterInterface;
+
+/**
+ * Emits a Response to the PHP Server API.
+ *
+ * This emitter offers a few changes from the emitters offered by
+ * diactoros:
+ *
+ * - It logs headers sent using CakePHP's logging tools.
+ * - Cookies are emitted using setcookie() to not conflict with ext/session
+ */
+class ResponseEmitter implements EmitterInterface
+{
+    /**
+     * {@inheritDoc}
+     */
+    public function emit(ResponseInterface $response, $maxBufferLength = 8192)
+    {
+        $file = $line = null;
+        if (headers_sent($file, $line)) {
+            $message = "Unable to emit headers. Headers sent in file=$file line=$line";
+            if (Configure::read('debug')) {
+                trigger_error($message, E_USER_WARNING);
+            } else {
+                Log::warn($message);
+            }
+        }
+
+        $this->emitStatusLine($response);
+        $this->emitHeaders($response);
+        $this->flush();
+
+        $range = $this->parseContentRange($response->getHeaderLine('Content-Range'));
+
+        if (is_array($range)) {
+            $this->emitBodyRange($range, $response, $maxBufferLength);
+
+            return;
+        }
+
+        $this->emitBody($response, $maxBufferLength);
+
+        if (function_exists('fastcgi_finish_request')) {
+            fastcgi_finish_request();
+        }
+    }
+
+    /**
+     * Emit the message body.
+     *
+     * @param \Psr\Http\Message\ResponseInterface $response The response to emit
+     * @param int $maxBufferLength The chunk size to emit
+     * @return void
+     */
+    protected function emitBody(ResponseInterface $response, $maxBufferLength)
+    {
+        $body = $response->getBody();
+
+        if (!$body->isSeekable()) {
+            echo $body;
+
+            return;
+        }
+
+        $body->rewind();
+        while (!$body->eof()) {
+            echo $body->read($maxBufferLength);
+        }
+    }
+
+    /**
+     * Emit a range of the message body.
+     *
+     * @param array $range The range data to emit
+     * @param \Psr\Http\Message\ResponseInterface $response The response to emit
+     * @param int $maxBufferLength The chunk size to emit
+     * @return void
+     */
+    protected function emitBodyRange(array $range, ResponseInterface $response, $maxBufferLength)
+    {
+        list($unit, $first, $last, $length) = $range;
+
+        $body = $response->getBody();
+
+        if (!$body->isSeekable()) {
+            $contents = $body->getContents();
+            echo substr($contents, $first, $last - $first + 1);
+
+            return;
+        }
+
+        $body = new RelativeStream($body, $first);
+        $body->rewind();
+        $pos = 0;
+        $length = $last - $first + 1;
+        while (!$body->eof() && $pos < $length) {
+            if (($pos + $maxBufferLength) > $length) {
+                echo $body->read($length - $pos);
+                break;
+            }
+
+            echo $body->read($maxBufferLength);
+            $pos = $body->tell();
+        }
+    }
+
+    /**
+     * Emit the status line.
+     *
+     * Emits the status line using the protocol version and status code from
+     * the response; if a reason phrase is availble, it, too, is emitted.
+     *
+     * @param \Psr\Http\Message\ResponseInterface $response The response to emit
+     * @return void
+     */
+    protected function emitStatusLine(ResponseInterface $response)
+    {
+        $reasonPhrase = $response->getReasonPhrase();
+        header(sprintf(
+            'HTTP/%s %d%s',
+            $response->getProtocolVersion(),
+            $response->getStatusCode(),
+            ($reasonPhrase ? ' ' . $reasonPhrase : '')
+        ));
+    }
+
+    /**
+     * Emit response headers.
+     *
+     * Loops through each header, emitting each; if the header value
+     * is an array with multiple values, ensures that each is sent
+     * in such a way as to create aggregate headers (instead of replace
+     * the previous).
+     *
+     * @param \Psr\Http\Message\ResponseInterface $response The response to emit
+     * @return void
+     */
+    protected function emitHeaders(ResponseInterface $response)
+    {
+        foreach ($response->getHeaders() as $name => $values) {
+            if (strtolower($name) === 'set-cookie') {
+                $this->emitCookies($values);
+                continue;
+            }
+            $first = true;
+            foreach ($values as $value) {
+                header(sprintf(
+                    '%s: %s',
+                    $name,
+                    $value
+                ), $first);
+                $first = false;
+            }
+        }
+    }
+
+    /**
+     * Emit cookies using setcookie()
+     *
+     * @param array $cookies An array of Set-Cookie headers.
+     * @return void
+     */
+    protected function emitCookies(array $cookies)
+    {
+        foreach ((array)$cookies as $cookie) {
+            if (strpos($cookie, '";"') !== false) {
+                $cookie = str_replace('";"', "{__cookie_replace__}", $cookie);
+                $parts = str_replace("{__cookie_replace__}", '";"', explode(';', $cookie));
+            } else {
+                $parts = preg_split('/\;[ \t]*/', $cookie);
+            }
+
+            list($name, $value) = explode('=', array_shift($parts), 2);
+            $data = [
+                'name' => $name,
+                'value' => $value,
+                'expires' => 0,
+                'path' => '',
+                'domain' => '',
+                'secure' => false,
+                'httponly' => false
+            ];
+
+            foreach ($parts as $part) {
+                if (strpos($part, '=') !== false) {
+                    list($key, $value) = explode('=', $part);
+                } else {
+                    $key = $part;
+                    $value = true;
+                }
+
+                $key = strtolower($key);
+                $data[$key] = $value;
+            }
+            if (!empty($data['expires'])) {
+                $data['expires'] = strtotime($data['expires']);
+            }
+            setcookie(
+                $data['name'],
+                $data['value'],
+                $data['expires'],
+                $data['path'],
+                $data['domain'],
+                $data['secure'],
+                $data['httponly']
+            );
+        }
+    }
+
+    /**
+     * Loops through the output buffer, flushing each, before emitting
+     * the response.
+     *
+     * @param int|null $maxBufferLevel Flush up to this buffer level.
+     * @return void
+     */
+    protected function flush($maxBufferLevel = null)
+    {
+        if (null === $maxBufferLevel) {
+            $maxBufferLevel = ob_get_level();
+        }
+
+        while (ob_get_level() > $maxBufferLevel) {
+            ob_end_flush();
+        }
+    }
+
+    /**
+     * Parse content-range header
+     * http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.16
+     *
+     * @param string $header The Content-Range header to parse.
+     * @return false|array [unit, first, last, length]; returns false if no
+     *     content range or an invalid content range is provided
+     */
+    protected function parseContentRange($header)
+    {
+        if (preg_match('/(?P<unit>[\w]+)\s+(?P<first>\d+)-(?P<last>\d+)\/(?P<length>\d+|\*)/', $header, $matches)) {
+            return [
+                $matches['unit'],
+                (int)$matches['first'],
+                (int)$matches['last'],
+                $matches['length'] === '*' ? '*' : (int)$matches['length'],
+            ];
+        }
+
+        return false;
+    }
+}

+ 0 - 17
src/Http/ResponseTransformer.php

@@ -163,23 +163,6 @@ class ResponseTransformer
             $headers = static::setContentType($headers, $response);
         }
         $cookies = $response->cookie();
-        if ($cookies && (
-            session_status() === \PHP_SESSION_ACTIVE ||
-            PHP_SAPI === 'cli' ||
-            PHP_SAPI === 'phpdbg'
-        )) {
-            $sessionCookie = session_get_cookie_params();
-            $sessionName = session_name();
-            $cookies[$sessionName] = [
-                'name' => $sessionName,
-                'path' => $sessionCookie['path'],
-                'value' => session_id(),
-                'expire' => $sessionCookie['lifetime'],
-                'secure' => $sessionCookie['secure'],
-                'domain' => $sessionCookie['domain'],
-                'httpOnly' => $sessionCookie['httponly'],
-            ];
-        }
         if ($cookies) {
             $headers['Set-Cookie'] = static::buildCookieHeader($cookies);
         }

+ 1 - 4
src/Http/Server.php

@@ -112,11 +112,8 @@ class Server
     public function emit(ResponseInterface $response, EmitterInterface $emitter = null)
     {
         $stream = $response->getBody();
-        if (!$emitter && !$stream->isSeekable()) {
-            $emitter = new SapiEmitter();
-        }
         if (!$emitter) {
-            $emitter = new SapiStreamEmitter();
+            $emitter = new ResponseEmitter();
         }
         $emitter->emit($response);
     }

+ 271 - 0
tests/TestCase/Http/ResponseEmitterTest.php

@@ -0,0 +1,271 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         3.3.5
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase;
+
+use Cake\Http\CallbackStream;
+use Cake\Http\ResponseEmitter;
+use Cake\TestSuite\TestCase;
+use Zend\Diactoros\Response;
+use Zend\Diactoros\Stream;
+use Zend\Diactoros\ServerRequestFactory;
+
+require_once __DIR__ . '/server_mocks.php';
+
+/**
+ * Response emitter test.
+ */
+class ResponseEmitterTest extends TestCase
+{
+    protected $emitter;
+
+    public function setUp()
+    {
+        parent::setUp();
+        $GLOBALS['mockedHeaders'] = $GLOBALS['mockedCookies'] = [];
+        $this->emitter = new ResponseEmitter();
+    }
+
+    /**
+     * Test emitting simple responses.
+     *
+     * @return void
+     */
+    public function testEmitResponseSimple()
+    {
+        $response = (new Response())
+            ->withStatus(201)
+            ->withHeader('Content-Type', 'text/html')
+            ->withHeader('Location', 'http://example.com/cake/1');
+        $response->getBody()->write('It worked');
+
+        ob_start();
+        $this->emitter->emit($response);
+        $out = ob_get_clean();
+
+        $this->assertEquals('It worked', $out);
+        $expected = [
+            'HTTP/1.1 201 Created',
+            'Content-Type: text/html',
+            'Location: http://example.com/cake/1'
+        ];
+        $this->assertEquals($expected, $GLOBALS['mockedHeaders']);
+    }
+
+    /**
+     * Test emitting responses with cookies
+     *
+     * @return void
+     */
+    public function testEmitResponseCookies()
+    {
+        $response = (new Response())
+            ->withAddedHeader('Set-Cookie', "simple=val;\tSecure")
+            ->withAddedHeader('Set-Cookie', 'people=jim,jack,jonny";";Path=/accounts')
+            ->withAddedHeader('Set-Cookie', 'google=not=nice;Path=/accounts; HttpOnly')
+            ->withAddedHeader('Set-Cookie', 'a=b;  Expires=Wed, 13 Jan 2021 22:23:01 GMT; Domain=www.example.com;')
+            ->withHeader('Content-Type', 'text/plain');
+        $response->getBody()->write('ok');
+
+        ob_start();
+        $this->emitter->emit($response);
+        $out = ob_get_clean();
+
+        $this->assertEquals('ok', $out);
+        $expected = [
+            'HTTP/1.1 200 OK',
+            'Content-Type: text/plain'
+        ];
+        $this->assertEquals($expected, $GLOBALS['mockedHeaders']);
+        $expected = [
+            [
+                'name' => 'simple',
+                'value' => 'val',
+                'path' => '',
+                'expire' => 0,
+                'domain' => '',
+                'secure' => true,
+                'httponly' => false
+            ],
+            [
+                'name' => 'people',
+                'value' => 'jim,jack,jonny";"',
+                'path' => '/accounts',
+                'expire' => 0,
+                'domain' => '',
+                'secure' => false,
+                'httponly' => false
+            ],
+            [
+                'name' => 'google',
+                'value' => 'not=nice',
+                'path' => '/accounts',
+                'expire' => 0,
+                'domain' => '',
+                'secure' => false,
+                'httponly' => true
+            ],
+            [
+                'name' => 'a',
+                'value' => 'b',
+                'path' => '',
+                'expire' => 1610576581,
+                'domain' => 'www.example.com',
+                'secure' => false,
+                'httponly' => false
+            ],
+        ];
+        $this->assertEquals($expected, $GLOBALS['mockedCookies']);
+    }
+
+    /**
+     * Test emitting responses using callback streams.
+     *
+     * We use callback streams for closure based responses.
+     *
+     * @return void
+     */
+    public function testEmitResponseCallbackStream()
+    {
+        $stream = new CallbackStream(function () {
+            echo 'It worked';
+        });
+        $response = (new Response())
+            ->withStatus(201)
+            ->withBody($stream)
+            ->withHeader('Content-Type', 'text/plain');
+
+        ob_start();
+        $this->emitter->emit($response);
+        $out = ob_get_clean();
+
+        $this->assertEquals('It worked', $out);
+        $expected = [
+            'HTTP/1.1 201 Created',
+            'Content-Type: text/plain',
+        ];
+        $this->assertEquals($expected, $GLOBALS['mockedHeaders']);
+    }
+
+    /**
+     * Test valid body ranges.
+     *
+     * @return void
+     */
+    public function testEmitResponseBodyRange()
+    {
+        $response = (new Response())
+            ->withHeader('Content-Type', 'text/plain')
+            ->withHeader('Content-Range', 'bytes 1-4/9');
+        $response->getBody()->write('It worked');
+
+        ob_start();
+        $this->emitter->emit($response);
+        $out = ob_get_clean();
+
+        $this->assertEquals('t wo', $out);
+        $expected = [
+            'HTTP/1.1 200 OK',
+            'Content-Type: text/plain',
+            'Content-Range: bytes 1-4/9',
+        ];
+        $this->assertEquals($expected, $GLOBALS['mockedHeaders']);
+    }
+
+    /**
+     * Test valid body ranges.
+     *
+     * @return void
+     */
+    public function testEmitResponseBodyRangeComplete()
+    {
+        $response = (new Response())
+            ->withHeader('Content-Type', 'text/plain')
+            ->withHeader('Content-Range', 'bytes 0-20/9');
+        $response->getBody()->write('It worked');
+
+        ob_start();
+        $this->emitter->emit($response, 2);
+        $out = ob_get_clean();
+
+        $this->assertEquals('It worked', $out);
+    }
+
+    /**
+     * Test out of bounds body ranges.
+     *
+     * @return void
+     */
+    public function testEmitResponseBodyRangeOverflow()
+    {
+        $response = (new Response())
+            ->withHeader('Content-Type', 'text/plain')
+            ->withHeader('Content-Range', 'bytes 5-20/9');
+        $response->getBody()->write('It worked');
+
+        ob_start();
+        $this->emitter->emit($response);
+        $out = ob_get_clean();
+
+        $this->assertEquals('rked', $out);
+    }
+
+    /**
+     * Test malformed content-range header
+     *
+     * @return void
+     */
+    public function testEmitResponseBodyRangeMalformed()
+    {
+        $response = (new Response())
+            ->withHeader('Content-Type', 'text/plain')
+            ->withHeader('Content-Range', 'bytes 9-ba/a');
+        $response->getBody()->write('It worked');
+
+        ob_start();
+        $this->emitter->emit($response);
+        $out = ob_get_clean();
+
+        $this->assertEquals('It worked', $out);
+    }
+
+    /**
+     * Test callback streams returning content and ranges
+     *
+     * @return void
+     */
+    public function testEmitResponseBodyRangeCallbackStream()
+    {
+        $stream = new CallbackStream(function () {
+            return 'It worked';
+        });
+        $response = (new Response())
+            ->withStatus(201)
+            ->withBody($stream)
+            ->withHeader('Content-Range', 'bytes 1-4/9')
+            ->withHeader('Content-Type', 'text/plain');
+
+        ob_start();
+        $this->emitter->emit($response);
+        $out = ob_get_clean();
+
+        $this->assertEquals('t wo', $out);
+        $expected = [
+            'HTTP/1.1 201 Created',
+            'Content-Range: bytes 1-4/9',
+            'Content-Type: text/plain',
+        ];
+        $this->assertEquals($expected, $GLOBALS['mockedHeaders']);
+    }
+}

+ 0 - 22
tests/TestCase/Http/ResponseTransformerTest.php

@@ -206,28 +206,6 @@ class ResponseTransformerTest extends TestCase
     }
 
     /**
-     * Test conversion setting cookies including the session cookie
-     *
-     * @return void
-     */
-    public function testToPsrCookieWithSession()
-    {
-        $session = new Session();
-        $session->write('things', 'things');
-        $cake = new CakeResponse(['status' => 200]);
-        $cake->cookie([
-            'name' => 'remember_me',
-            'value' => 1
-        ]);
-        $result = ResponseTransformer::toPsr($cake);
-        $this->assertEquals(
-            'remember_me=1; Path=/,CAKEPHP=; Path=/; HttpOnly',
-            $result->getHeaderLine('Set-Cookie'),
-            'Session cookie data was not retained.'
-        );
-    }
-
-    /**
      * Test conversion setting multiple cookies
      *
      * @return void

+ 1 - 1
tests/TestCase/Http/ServerTest.php

@@ -23,7 +23,7 @@ use TestApp\Http\MiddlewareApplication;
 use Zend\Diactoros\Response;
 use Zend\Diactoros\ServerRequestFactory;
 
-require __DIR__ . '/server_mocks.php';
+require_once __DIR__ . '/server_mocks.php';
 
 
 /**

+ 17 - 1
tests/TestCase/Http/server_mocks.php

@@ -1,5 +1,8 @@
 <?php
-namespace Zend\Diactoros\Response;
+/**
+ * A set of 'mocks' that replace the PHP global functions to aid testing.
+ */
+namespace Cake\Http;
 
 function headers_sent()
 {
@@ -10,3 +13,16 @@ function header($header)
 {
     $GLOBALS['mockedHeaders'][] = $header;
 }
+
+function setcookie($name, $value, $expire, $path, $domain, $secure = false, $httponly = false)
+{
+    $GLOBALS['mockedCookies'][] = compact(
+        'name',
+        'value',
+        'expire',
+        'path',
+        'domain',
+        'secure',
+        'httponly'
+    );
+}