Browse Source

Add RequestFactory and RequestTransformer.

The ServerRequestFactory is responsible for:

* Building a request from the SAPI super globals.
* Extracting the base and webroot directories for backwards
  compatibility with the CakeRequest.
* Updating the request path to reflect only the application's
  'virtual path'

The RequestTransformer handles:

* Converting a PSR7 request object into the equivalent Cake\Network\Http
  request.
* Ensuring that the required routing parameters are set even if the PSR7
  request is missing the 'params' attribute.

In order to shim the backwards compatibility, we'll use 3 attributes
which keep track of CakePHP specific path information and our routing
parameters. I felt this was the cleanest approach as I wasn't
comfortable subclassing Diactoros\ServerRequest to add methods for these
just yet.
Mark Story 10 years ago
parent
commit
ed8cbc10f7

+ 140 - 0
src/Http/RequestTransformer.php

@@ -0,0 +1,140 @@
+<?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.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Http;
+
+use Cake\Core\Configure;
+use Cake\Network\Request as CakeRequest;
+use Cake\Utility\Hash;
+use Psr\Http\Message\ServerRequestInterface as PsrRequest;
+
+/**
+ * Translate and transform from PSR7 requests into CakePHP requests.
+ *
+ * This is an important step for maintaining backwards compatibility
+ * with existing CakePHP applications, which depend on the CakePHP request object.
+ *
+ * There is no reverse transform as the 'application' cannot return a mutated
+ * request object.
+ *
+ * @internal
+ */
+class RequestTransformer
+{
+    /**
+     * Transform a PSR7 request into a CakePHP one.
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The PSR7 request.
+     * @return \Cake\Network\Request The transformed request.
+     */
+    public static function toCake(PsrRequest $request)
+    {
+        $post = $request->getParsedBody();
+        $server = $request->getServerParams();
+
+        $files = static::getFiles($request);
+        if (!empty($files)) {
+            $post = Hash::merge($post, $files);
+        }
+
+        return new CakeRequest([
+            'query' => $request->getQueryParams(),
+            'post' => $post,
+            'cookies' => $request->getCookieParams(),
+            'environment' => $server,
+            'params' => static::getParams($request),
+            'url' => $request->getUri()->getPath(),
+            'base' => $request->getAttribute('base', ''),
+            'webroot' => $request->getAttribute('webroot', '/'),
+        ]);
+    }
+
+    /**
+     * Extract the routing parameters out of the request object.
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request to extract params from.
+     * @return array The routing parameters.
+     */
+    protected static function getParams(PsrRequest $request)
+    {
+        $params = (array)$request->getAttribute('params', []);
+        $params += [
+            'plugin' => null,
+            'controller' => null,
+            'action' => null,
+            '_ext' => null,
+            'pass' => []
+        ];
+        return $params;
+    }
+
+    /**
+     * Extract the uploaded files out of the request object.
+     *
+     * CakePHP expects to get arrays of file information and
+     * not the parsed objects that PSR7 requests contain. Downsample the data here.
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request to extract files from.
+     * @return array The routing parameters.
+     */
+    protected static function getFiles($request)
+    {
+        return static::convertFiles([], $request->getUploadedFiles());
+    }
+
+    /**
+     * Convert a nested array of files to arrays.
+     *
+     * @param array $data The data to add files to.
+     * @param array $files The file objects to convert.
+     * @param string $path The current array path.
+     * @return array Converted file data
+     */
+    protected static function convertFiles($data, $files, $path = '')
+    {
+        foreach ($files as $key => $file) {
+            $newPath = $path;
+            if ($newPath === '') {
+                $newPath = $key;
+            }
+            if ($newPath !== $key) {
+                $newPath .= '.' . $key;
+            }
+
+            if (is_array($file)) {
+                $data = static::convertFiles($data, $file, $newPath);
+            } else {
+                $data = Hash::insert($data, $newPath, static::convertFile($file));
+            }
+        }
+        return $data;
+    }
+
+    /**
+     * Convert a single file back into an array.
+     *
+     * @param \Psr\Http\Message\UploadedFileInterface $file The file to convert.
+     * @return array
+     */
+    protected static function convertFile($file)
+    {
+        return [
+            'name' => $file->getClientFilename(),
+            'type' => $file->getClientMediaType(),
+            'tmp_name' => $file->getStream()->getMetadata('uri'),
+            'error' => $file->getError(),
+            'size' => $file->getSize(),
+        ];
+    }
+}

+ 136 - 0
src/Http/ServerRequestFactory.php

@@ -0,0 +1,136 @@
+<?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.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Http;
+
+use Cake\Core\Configure;
+use Cake\Utility\Hash;
+use Zend\Diactoros\ServerRequestFactory as BaseFactory;
+
+/**
+ * Factory for making ServerRequest instances.
+ *
+ * This subclass adds in CakePHP specific behavior to populate
+ * the basePath and webroot attributes. Furthermore the Uri's path
+ * is corrected to only contain the 'virtual' path for the request.
+ */
+abstract class ServerRequestFactory extends BaseFactory
+{
+    /**
+     * {@inheritDoc}
+     */
+    public static function fromGlobals(
+        array $server = null,
+        array $query = null,
+        array $body = null,
+        array $cookies = null,
+        array $files = null
+    ) {
+        $request = parent::fromGlobals($server, $query, $body, $cookies, $files);
+        list($base, $webroot) = static::getBase($request);
+        $request = $request->withAttribute('base', $base)
+            ->withAttribute('webroot', $webroot);
+        if ($base) {
+            $request = static::updatePath($base, $request);
+        }
+        return $request;
+    }
+
+    /**
+     * Updates the request URI to remove the base directory.
+     *
+     * @param string $base The base path to remove.
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request to modify.
+     * @return \Psr\Http\Message\ServerRequestInterface The modified request.
+     */
+    protected static function updatePath($base, $request)
+    {
+        $uri = $request->getUri();
+        $path = $uri->getPath();
+        if (strlen($base) > 0 && strpos($path, $base) === 0) {
+            $path = substr($path, strlen($base));
+        }
+        if (empty($path) || $path === '/' || $path === '//' || $path === '/index.php') {
+            $path = '/';
+        }
+        $endsWithIndex = '/webroot/index.php';
+        $endsWithLength = strlen($endsWithIndex);
+        if (strlen($path) >= $endsWithLength &&
+            substr($path, -$endsWithLength) === $endsWithIndex
+        ) {
+            $path = '/';
+        }
+        return $request->withUri($uri->withPath($path));
+    }
+
+    /**
+     * Calculate the base directory and webroot directory.
+     *
+     * This code is a copy/paste from Cake\Network\Request::_base()
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface
+     * @return array An array containing the [baseDir, webroot]
+     */
+    protected static function getBase($request)
+    {
+        $path = $request->getUri()->getPath();
+        $server = $request->getServerParams();
+
+        $base = $webroot = $baseUrl = null;
+        $config = Configure::read('App');
+        extract($config);
+
+        if ($base !== false && $base !== null) {
+            return [$base, $base . '/'];
+        }
+
+        if (!$baseUrl) {
+            $base = dirname(Hash::get($server, 'PHP_SELF'));
+            // Clean up additional / which cause following code to fail..
+            $base = preg_replace('#/+#', '/', $base);
+
+            $indexPos = strpos($base, '/' . $webroot . '/index.php');
+            if ($indexPos !== false) {
+                $base = substr($base, 0, $indexPos) . '/' . $webroot;
+            }
+            if ($webroot === basename($base)) {
+                $base = dirname($base);
+            }
+
+            if ($base === DIRECTORY_SEPARATOR || $base === '.') {
+                $base = '';
+            }
+            $base = implode('/', array_map('rawurlencode', explode('/', $base)));
+            return [$base, $base . '/'];
+        }
+
+        $file = '/' . basename($baseUrl);
+        $base = dirname($baseUrl);
+
+        if ($base === DIRECTORY_SEPARATOR || $base === '.') {
+            $base = '';
+        }
+        $webrootDir = $base . '/';
+
+        $docRoot = Hash::get($server, 'DOCUMENT_ROOT');
+        $docRootContainsWebroot = strpos($docRoot, $webroot);
+
+        if (!empty($base) || !$docRootContainsWebroot) {
+            if (strpos($webrootDir, '/' . $webroot . '/') === false) {
+                $webrootDir .= $webroot . '/';
+            }
+        }
+        return [$base . $file, $webrootDir];
+    }
+}

+ 262 - 0
tests/TestCase/Http/RequestTransformerTest.php

@@ -0,0 +1,262 @@
+<?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.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Http;
+
+use Cake\Core\Configure;
+use Cake\Http\RequestTransformer;
+use Cake\Http\ServerRequestFactory;
+use Cake\Network\Request;
+use Cake\TestSuite\TestCase;
+
+/**
+ * Test for RequestTransformer
+ */
+class RequestTransformerTest extends TestCase
+{
+    /**
+     * Test transforming GET params.
+     *
+     * @return void
+     */
+    public function testToCakeGetParams()
+    {
+        $psr = ServerRequestFactory::fromGlobals(null, ['a' => 'b', 'c' => ['d' => 'e']]);
+        $cake = RequestTransformer::toCake($psr);
+        $this->assertEquals('b', $cake->query('a'));
+        $this->assertEquals(['d' => 'e'], $cake->query('c'));
+        $this->assertEmpty($cake->data);
+        $this->assertEmpty($cake->cookies);
+    }
+
+    /**
+     * Test transforming POST params.
+     *
+     * @return void
+     */
+    public function testToCakePostParams()
+    {
+        $psr = ServerRequestFactory::fromGlobals(null, null, ['title' => 'first post', 'some' => 'data']);
+        $cake = RequestTransformer::toCake($psr);
+        $this->assertEquals('first post', $cake->data('title'));
+        $this->assertEquals('data', $cake->data('some'));
+        $this->assertEmpty($cake->query);
+        $this->assertEmpty($cake->cookies);
+    }
+
+    /**
+     * Test transforming COOKIE params.
+     *
+     * @return void
+     */
+    public function testToCakeCookies()
+    {
+        $psr = ServerRequestFactory::fromGlobals(null, null, null, ['gtm' => 'watchingyou']);
+        $cake = RequestTransformer::toCake($psr);
+        $this->assertEmpty($cake->query);
+        $this->assertEmpty($cake->data);
+        $this->assertEquals('watchingyou', $cake->cookie('gtm'));
+    }
+
+    /**
+     * Test transforming header and server params.
+     *
+     * @return void
+     */
+    public function testToCakeHeadersAndEnvironment()
+    {
+        $server = [
+            'HTTPS' => 'on',
+            'HTTP_HOST' => 'example.com',
+            'REQUEST_METHOD' => 'PATCH',
+            'HTTP_ACCEPT' => 'application/json',
+            'SERVER_PROTOCOL' => '1.1',
+            'SERVER_PORT' => 443,
+        ];
+        $psr = ServerRequestFactory::fromGlobals($server);
+        $cake = RequestTransformer::toCake($psr);
+        $this->assertEmpty($cake->query);
+        $this->assertEmpty($cake->data);
+        $this->assertEmpty($cake->cookie);
+
+        $this->assertSame('application/json', $cake->header('accept'));
+        $this->assertSame('PATCH', $cake->method());
+        $this->assertSame('https', $cake->scheme());
+        $this->assertSame(443, $cake->port());
+        $this->assertSame('example.com', $cake->host());
+    }
+
+    /**
+     * Test transforming with no routing parameters
+     * still has the required keys.
+     *
+     * @return void
+     */
+    public function testToCakeParamsEmpty()
+    {
+        $psr = ServerRequestFactory::fromGlobals();
+        $cake = RequestTransformer::toCake($psr);
+
+        $this->assertArrayHasKey('controller', $cake->params);
+        $this->assertArrayHasKey('action', $cake->params);
+        $this->assertArrayHasKey('plugin', $cake->params);
+        $this->assertArrayHasKey('_ext', $cake->params);
+        $this->assertArrayHasKey('pass', $cake->params);
+    }
+
+    /**
+     * Test transforming with non-empty params.
+     *
+     * @return void
+     */
+    public function testToCakeParamsPopulated()
+    {
+        $psr = ServerRequestFactory::fromGlobals();
+        $psr = $psr->withAttribute('params', ['controller' => 'Articles', 'action' => 'index']);
+        $cake = RequestTransformer::toCake($psr);
+
+        $this->assertEmpty($cake->query);
+        $this->assertEmpty($cake->data);
+        $this->assertEmpty($cake->cookie);
+
+        $this->assertSame('Articles', $cake->param('controller'));
+        $this->assertSame('index', $cake->param('action'));
+        $this->assertArrayHasKey('plugin', $cake->params);
+        $this->assertArrayHasKey('_ext', $cake->params);
+        $this->assertArrayHasKey('pass', $cake->params);
+    }
+
+    /**
+     * Test transforming uploaded files
+     *
+     * @return void
+     */
+    public function testToCakeUploadedFiles()
+    {
+        $files = [
+            'image_main' => [
+                'name' => ['file' => 'born on.txt'],
+                'type' => ['file' => 'text/plain'],
+                'tmp_name' => ['file' => __FILE__],
+                'error' => ['file' => 0],
+                'size' => ['file' => 17178]
+            ],
+            0 => [
+                'name' => ['image' => 'scratch.text'],
+                'type' => ['image' => 'text/plain'],
+                'tmp_name' => ['image' => __FILE__],
+                'error' => ['image' => 0],
+                'size' => ['image' => 1490]
+            ],
+            'pictures' => [
+                'name' => [
+                    0 => ['file' => 'a-file.png'],
+                    1 => ['file' => 'a-moose.png']
+                ],
+                'type' => [
+                    0 => ['file' => 'image/png'],
+                    1 => ['file' => 'image/jpg']
+                ],
+                'tmp_name' => [
+                    0 => ['file' => __FILE__],
+                    1 => ['file' => __FILE__]
+                ],
+                'error' => [
+                    0 => ['file' => 0],
+                    1 => ['file' => 0]
+                ],
+                'size' => [
+                    0 => ['file' => 17188],
+                    1 => ['file' => 2010]
+                ],
+            ]
+        ];
+        $post = [
+            'pictures' => [
+                0 => ['name' => 'A cat'],
+                1 => ['name' => 'A moose']
+            ],
+            0 => [
+                'name' => 'A dog'
+            ]
+        ];
+        $psr = ServerRequestFactory::fromGlobals(null, null, $post, null, $files);
+        $request = RequestTransformer::toCake($psr);
+        $expected = [
+            'image_main' => [
+                'file' => [
+                    'name' => 'born on.txt',
+                    'type' => 'text/plain',
+                    'tmp_name' => __FILE__,
+                    'error' => 0,
+                    'size' => 17178,
+                ]
+            ],
+            'pictures' => [
+                0 => [
+                    'name' => 'A cat',
+                    'file' => [
+                        'name' => 'a-file.png',
+                        'type' => 'image/png',
+                        'tmp_name' => __FILE__,
+                        'error' => 0,
+                        'size' => 17188,
+                    ]
+                ],
+                1 => [
+                    'name' => 'A moose',
+                    'file' => [
+                        'name' => 'a-moose.png',
+                        'type' => 'image/jpg',
+                        'tmp_name' => __FILE__,
+                        'error' => 0,
+                        'size' => 2010,
+                    ]
+                ]
+            ],
+            0 => [
+                'name' => 'A dog',
+                'image' => [
+                    'name' => 'scratch.text',
+                    'type' => 'text/plain',
+                    'tmp_name' => __FILE__,
+                    'error' => 0,
+                    'size' => 1490
+                ]
+            ]
+        ];
+        $this->assertEquals($expected, $request->data);
+    }
+
+    /**
+     * Test that the transformed request sets the session path
+     * as expected.
+     *
+     * @return void
+     */
+    public function testToCakeBaseSessionPath()
+    {
+        Configure::write('App.baseUrl', false);
+
+        $server = [
+            'DOCUMENT_ROOT' => '/cake/repo/branches',
+            'PHP_SELF' => '/thisapp/webroot/index.php',
+            'REQUEST_URI' => '/posts/view/1',
+        ];
+        $psr = ServerRequestFactory::fromGlobals($server);
+        $cake = RequestTransformer::toCake($psr);
+
+        $this->assertEquals('/thisapp', ini_get('session.cookie_path'));
+    }
+}

+ 187 - 0
tests/TestCase/Http/ServerRequestFactoryTest.php

@@ -0,0 +1,187 @@
+<?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.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Http;
+
+use Cake\Core\Configure;
+use Cake\Http\ServerRequestFactory;
+use Cake\TestSuite\TestCase;
+
+/**
+ * Test case for the server factory.
+ */
+class ServerRequestFactoryTest extends TestCase
+{
+    /**
+     * setup
+     *
+     * @return void
+     */
+    public function setUp()
+    {
+        parent::setUp();
+        $this->server = $_SERVER;
+    }
+
+    /**
+     * teardown
+     *
+     * @return void
+     */
+    public function tearDown()
+    {
+        parent::tearDown();
+        $_SERVER = $this->server;
+    }
+
+    /**
+     * Test fromGlobals with App.base defined.
+     *
+     * @return void
+     */
+    public function testFromGlobalsUrlBaseDefined()
+    {
+        Configure::write('App.base', 'basedir');
+        $server = [
+            'DOCUMENT_ROOT' => '/cake/repo/branches/1.2.x.x/webroot',
+            'PHP_SELF' => '/index.php',
+            'REQUEST_URI' => '/posts/add',
+        ];
+        $res = ServerRequestFactory::fromGlobals($server);
+        $this->assertEquals('basedir', $res->getAttribute('base'));
+        $this->assertEquals('basedir/', $res->getAttribute('webroot'));
+        $this->assertEquals('/posts/add', $res->getUri()->getPath());
+    }
+
+    /**
+     * Test fromGlobals with mod-rewrite server configuration.
+     *
+     * @return void
+     */
+    public function testFromGlobalsUrlModRewrite()
+    {
+        Configure::write('App.baseUrl', false);
+
+        $server = [
+            'DOCUMENT_ROOT' => '/cake/repo/branches',
+            'PHP_SELF' => '/urlencode me/webroot/index.php',
+            'REQUEST_URI' => '/posts/view/1',
+        ];
+        $res = ServerRequestFactory::fromGlobals($server);
+
+        $this->assertEquals('/urlencode%20me', $res->getAttribute('base'));
+        $this->assertEquals('/urlencode%20me/', $res->getAttribute('webroot'));
+        $this->assertEquals('/posts/view/1', $res->getUri()->getPath());
+    }
+
+    /**
+     * Test fromGlobals with mod-rewrite in the root dir.
+     *
+     * @return void
+     */
+    public function testFromGlobalsUrlModRewriteRootDir()
+    {
+        $server = [
+            'DOCUMENT_ROOT' => '/cake/repo/branches/1.2.x.x/webroot',
+            'PHP_SELF' => '/index.php',
+            'REQUEST_URI' => '/posts/add',
+        ];
+        $res = ServerRequestFactory::fromGlobals($server);
+
+        $this->assertEquals('', $res->getAttribute('base'));
+        $this->assertEquals('/', $res->getAttribute('webroot'));
+        $this->assertEquals('/posts/add', $res->getUri()->getPath());
+    }
+
+    /**
+     * Test fromGlobals with App.baseUrl defined implying no
+     * mod-rewrite and no virtual path.
+     *
+     * @return void
+     */
+    public function testFromGlobalsUrlNoModRewriteWebrootDir()
+    {
+        Configure::write('App', [
+            'dir' => 'app',
+            'webroot' => 'webroot',
+            'base' => false,
+            'baseUrl' => '/cake/index.php'
+        ]);
+        $server = [
+            'DOCUMENT_ROOT' => '/Users/markstory/Sites',
+            'SCRIPT_FILENAME' => '/Users/markstory/Sites/cake/webroot/index.php',
+            'PHP_SELF' => '/cake/webroot/index.php/posts/index',
+            'REQUEST_URI' => '/cake/webroot/index.php',
+        ];
+        $res = ServerRequestFactory::fromGlobals($server);
+
+        $this->assertSame('/cake/webroot/', $res->getAttribute('webroot'));
+        $this->assertSame('/cake/index.php', $res->getAttribute('base'));
+        $this->assertSame('/', $res->getUri()->getPath());
+    }
+
+    /**
+     * Test fromGlobals with App.baseUrl defined implying no
+     * mod-rewrite
+     *
+     * @return void
+     */
+    public function testFromGlobalsUrlNoModRewrite()
+    {
+        Configure::write('App', [
+            'dir' => 'app',
+            'webroot' => 'webroot',
+            'base' => false,
+            'baseUrl' => '/cake/index.php'
+        ]);
+        $server = [
+            'DOCUMENT_ROOT' => '/Users/markstory/Sites',
+            'SCRIPT_FILENAME' => '/Users/markstory/Sites/cake/index.php',
+            'PHP_SELF' => '/cake/index.php/posts/index',
+            'REQUEST_URI' => '/cake/index.php/posts/index',
+        ];
+        $res = ServerRequestFactory::fromGlobals($server);
+
+        $this->assertSame('/cake/webroot/', $res->getAttribute('webroot'));
+        $this->assertSame('/cake/index.php', $res->getAttribute('base'));
+        $this->assertSame('/posts/index', $res->getUri()->getPath());
+    }
+
+    /**
+     * Test fromGlobals with App.baseUrl defined implying no
+     * mod-rewrite in the root dir.
+     *
+     * @return void
+     */
+    public function testFromGlobalsUrlNoModRewriteRootDir()
+    {
+        Configure::write('App', [
+            'dir' => 'cake',
+            'webroot' => 'webroot',
+            'base' => false,
+            'baseUrl' => '/index.php'
+        ]);
+        $server = [
+            'DOCUMENT_ROOT' => '/Users/markstory/Sites/cake',
+            'SCRIPT_FILENAME' => '/Users/markstory/Sites/cake/index.php',
+            'PHP_SELF' => '/index.php/posts/add',
+            'REQUEST_URI' => '/index.php/posts/add',
+        ];
+        $res = ServerRequestFactory::fromGlobals($server);
+
+        $this->assertEquals('/webroot/', $res->getAttribute('webroot'));
+        $this->assertEquals('/index.php', $res->getAttribute('base'));
+        $this->assertEquals('/posts/add', $res->getUri()->getPath());
+    }
+}