From 5e3f011cfa118bc1cf27dbe7b5decf23dbb8e2d4 Mon Sep 17 00:00:00 2001 From: "Timothy J. Warren" Date: Wed, 14 May 2014 16:58:34 -0400 Subject: [PATCH] Better accept header handling and lots more test coverage --- Sleepy/Core/Config.php | 36 ++++---- Sleepy/Core/Input.php | 26 +++--- Sleepy/Core/Output.php | 115 ++++++++++++++------------ application/bootstrap.php | 2 +- application/config/type_class_map.php | 53 ++++++------ index.php | 1 - tests/Bootstrap.php | 22 +++++ tests/Core/ConfigTest.php | 60 ++++++++++++++ tests/Core/OutputTest.php | 99 +++++++++++++++++++++- 9 files changed, 300 insertions(+), 114 deletions(-) create mode 100644 tests/Core/ConfigTest.php diff --git a/Sleepy/Core/Config.php b/Sleepy/Core/Config.php index 8ee7423..e0bb052 100755 --- a/Sleepy/Core/Config.php +++ b/Sleepy/Core/Config.php @@ -21,46 +21,42 @@ class Config { * @var array */ protected $data = []; - + /** - * Load the specified config file and return - * the config array - * - * @throws \InvalidArugmentException - * @param string $name - * @return array + * Load the data into the class member */ - public function load($name) + public function __construct() { - $file = APPPATH . 'config/'. $name . '.php'; + $conf_files = glob(APPPATH . 'config/*.php'); - if (is_file($file)) + foreach($conf_files as $file) { - $conf =require_once($file); + $data = require_once($file); + + $name = str_replace('.php', '', basename($file)); + $this->data[$name] = $data; } - else - { - throw new \InvalidArgumentException("The config file doesn't exist"); - } - - $this->data[$name] = $conf; } + /** * Get the specific parameter from the specified file * + * @throws \InvalidArgumentException * @param string $file * @param string $key * @return mixed */ public function get($file, $key=NULL) { - if (is_null($key)) + if ( ! array_key_exists($file, $this->data)) { - return $this->data[$file]; + throw new \InvalidArgumentException("The config file doesn't exist"); } - return $this->data[$file][$key]; + return (is_null($key)) + ? $this->data[$file] + : $this->data[$file][$key]; } } // End of Core/Config.php \ No newline at end of file diff --git a/Sleepy/Core/Input.php b/Sleepy/Core/Input.php index 8f340b1..4d65871 100755 --- a/Sleepy/Core/Input.php +++ b/Sleepy/Core/Input.php @@ -306,26 +306,25 @@ class Input { protected function parse_accept_header($value) { $q_types = []; - // A fake value so I can shift it off to have a 1-indexed array $high_types = []; $count = 1; // Split into segments of different values - $groups = explode(',', $value); + $groups = \explode(',', $value); foreach($groups as $group) { $group = \trim($group); - $pair = explode(';q=', $group); + $pair = \explode(';q=', $group); - if (count($pair) === 2) + if (\count($pair) === 2) { list($val, $q) = $pair; $q_types[$q] = $val; } else { - $high_types[$count] = current($pair); + $high_types[$count] = \current($pair); $count++; } } @@ -333,7 +332,7 @@ class Input { // Add an additional fake value so we can // have a 1-indexed array $high_types[$count] = 'foo'; - $high_types = array_reverse($high_types); + $high_types = \array_reverse($high_types); unset($high_types[0]); $output = $q_types; @@ -345,7 +344,7 @@ class Input { $output[$k] = $v; } - krsort($output, SORT_NUMERIC); + \krsort($output, SORT_NUMERIC); return $output; } @@ -367,16 +366,17 @@ class Input { // Return the whole array if the index is null if ($index === NULL) { - return ($filter !== NULL) ? \filter_var_array($var, $filter) : $var; + return ($filter !== NULL) + ? \filter_var_array($var, $filter) + : $var; } // Prevent errors for non-existant variables - if ( ! isset($var[$index])) - { - return NULL; - } + if ( ! isset($var[$index])) return NULL; - return ($filter !== NULL) ? \filter_var($var[$index], $filter) : $var[$index]; + return ($filter !== NULL) + ? \filter_var($var[$index], $filter) + : $var[$index]; } } diff --git a/Sleepy/Core/Output.php b/Sleepy/Core/Output.php index 219c46f..95b6540 100755 --- a/Sleepy/Core/Output.php +++ b/Sleepy/Core/Output.php @@ -69,6 +69,7 @@ class Output { /** * Output the data to the client + * @codeCoverageIgnore */ public function __destruct() { @@ -89,7 +90,12 @@ class Output { { if (is_array($header)) { - array_merge($this->headers, $header); + foreach($header as $type => $val) + { + $this->headers[] = (is_numeric($type)) + ? $val + : "{$type}: {$val}"; + } } else { @@ -100,7 +106,7 @@ class Output { /** * Set the data to be output to the endpoint * - * @param string $type - The data format to send + * @param mixed $type - The data format to send * @param mixed $data - The data to send * @return void */ @@ -116,7 +122,7 @@ class Output { // Get the appropriate output format for the client // And set the data - $this->get_accepted_type($type, $this->data); + return $this->get_accepted_type($type, $this->data); } // -------------------------------------------------------------------------- @@ -128,85 +134,92 @@ class Output { * * @param mixed $types * @param mixed $data - * @return void + * @return array */ protected function get_accepted_type($types, $data) { + // Get the mime - type class mapping to use + // for determining which classes need to be loaded $types = (array) $types; $types = array_map('strtoupper', $types); + $type_map = $this->config->get('type_class_map'); + $filtered_type_map = $this->filter_mime_types($type_map, $types); + // Get the accept headers to filter valid mime types + // for the current client $headers = $this->input->header_array(); $accept = array_flip($headers['accept']); + $valid_mimes = array_keys(array_intersect_key($accept, $filtered_type_map)); + + // When you don't have a matching mime, send the default + // data type specified for the output in the type_class_map + // config file + if (empty($valid_mimes)) + { + $valid_mimes[] = $type_map['*/*']; + } - $type_map = []; - $accepted = []; + // Map type objects to the appropriate + // associated mime types $classes = []; - - foreach($types as $t) + foreach($valid_mimes as $mime) { + $t = $type_map[$mime]; $type_class = "Sleepy\\Type\\{$t}"; - $classes[$type_class] = new $type_class($data); - $mime = $classes[$type_class]->get_mime(); - - $type_map[$mime] = $type_class; - } - - // Order type preference first by - // input, then by accept flags - foreach($type_map as $type => $obj) - { - if (\array_key_exists($type, $accept)) - { - $q = $accept[$type]; - - if ($q >= 1) - { - $accepted[] = $type; - } - else - { - $accepted[$q] = $type; - } - } - } - - // Default to html fow wildcard accept values - if (empty($accepted) && \array_key_exists('*/*', $accept)) - { - $accepted[1] = 'text/html'; + $classes[$mime] = $type_class; } // Use the first output type to output the data - $class = $type_map[current($accepted)]; - $this->type_wrapper = $classes[$class]; + $selected_mime = array_shift($valid_mimes); + $class = $classes[$selected_mime]; + $this->type_wrapper = new $class($data); // Make sure to set the content-type header if (empty($this->headers)) { $mime = $this->type_wrapper->get_mime(); - $this->set_header("Content-type: {$mime}"); + $this->set_header("Content-type: {$mime};charset=utf8"); } + + return [$selected_mime => $class]; + } + + /** + * Filter the list of mime types by their values + * + * @param array $mime_list + * @param array $type_list + * @return array + */ + protected function filter_mime_types($mime_list, $type_list) + { + $filtered_list = []; + + foreach($type_list as $class) + { + foreach($mime_list as $mime => $c) + { + if (strtoupper($c) === strtoupper($class)) + { + $filtered_list[$mime] = $c; + } + } + } + + return $filtered_list; } /** * Set the applicable response headers * + * @codeCoverageIgnore * @return void */ protected function _output_headers() { - foreach($this->headers as $name => $val) + foreach($this->headers as $header) { - if (is_numeric($name)) - { - $output_header = $val; - } - else - { - $output_header = implode(": ", [$name, $val]); - } - - @header($output_header); + @header($header); } } } diff --git a/application/bootstrap.php b/application/bootstrap.php index d7d585f..82e1a11 100644 --- a/application/bootstrap.php +++ b/application/bootstrap.php @@ -35,7 +35,7 @@ $di->set('output', function () use ($di) { $browser = get_browser(); $browser->browser_name_regex = \utf8_encode($browser->browser_name_regex); $i = $di->get('input'); -$di->get('output')->set_data(['json','yaml','xml','html'], [ +$di->get('output')->set_data(['json','yaml'], [ '$_SERVER' => $i->server(), '$_GET' => $i->get(), '$_POST' => $i->post(), diff --git a/application/config/type_class_map.php b/application/config/type_class_map.php index 047ecb2..8b37ca8 100644 --- a/application/config/type_class_map.php +++ b/application/config/type_class_map.php @@ -1,26 +1,29 @@ - 'JSON', - 'text/yaml' => 'YAML', - 'application/yaml' => 'YAML', - 'text/html' => 'HTML', - 'application/xhtml+xml' => 'HTML', - '*/*' => 'HTML' -]; - + 'JSON', + 'text/yaml' => 'YAML', + 'application/yaml' => 'YAML', + 'text/html' => 'HTML', + 'text/xml' => 'XML', + 'application/xml' => 'XML', + + // Default mime type for cases without an explicit accept header match + '*/*' => 'text/html' +]; + // End of config/type_class_map.php \ No newline at end of file diff --git a/index.php b/index.php index 9bbadd5..d9c157b 100755 --- a/index.php +++ b/index.php @@ -24,5 +24,4 @@ require BASEPATH . SLEEPY_DIR . '/autoload.php'; // And...bootstrap require APPPATH . 'bootstrap.php'; - // End of index.php \ No newline at end of file diff --git a/tests/Bootstrap.php b/tests/Bootstrap.php index 4ed6761..d472620 100755 --- a/tests/Bootstrap.php +++ b/tests/Bootstrap.php @@ -21,3 +21,25 @@ class Sleepy_TestCase extends PHPUnit_Framework_TestCase { } } } + +// -------------------------------------------------------------------------- +// ! Simple Base Mocks +// -------------------------------------------------------------------------- + +use Sleepy\Core\Output; +use Sleepy\Core\Config; + +class MockOutput extends Output { + use Sleepy\Traits\getSet; + + public function __destruct() + { + $this->_output_headers(); + } +} + +class MockConfig extends Config { + use Sleepy\Traits\getSet; +} + +// End of tests/Bootstrap.php \ No newline at end of file diff --git a/tests/Core/ConfigTest.php b/tests/Core/ConfigTest.php new file mode 100644 index 0000000..a0fbab0 --- /dev/null +++ b/tests/Core/ConfigTest.php @@ -0,0 +1,60 @@ +config = new MockConfig(); + $this->config->setData([ + 'foo' => [ + 'bar' => 'baz', + 'x' => '-y', + 'p !=' => 'np' + ], + 'apple' => [1,3,5,7,9] + ]); + } + + public function dataGet() + { + return [ + 'single value' => [ + 'file' => 'foo', + 'key' => 'x', + 'expected' => '-y' + ], + 'whole array' => [ + 'file' => 'apple', + 'key' => NULL, + 'expected' => [1,3,5,7,9] + ] + ]; + } + + /** + * @dataProvider dataGet + */ + public function testGet($file, $key, $expected) + { + $res = $this->config->get($file, $key); + $this->assertEquals($expected, $res); + } + + + public function testBadGet() + { + try + { + $this->config->get('bleucheese'); + } + catch (\InvalidArgumentException $e) + { + $this->assertTrue(TRUE, "Proper exception was caught"); + } + } +} + +// End of ConfigTest.php \ No newline at end of file diff --git a/tests/Core/OutputTest.php b/tests/Core/OutputTest.php index a467faa..7ca719e 100644 --- a/tests/Core/OutputTest.php +++ b/tests/Core/OutputTest.php @@ -15,14 +15,107 @@ class OutputTest extends Sleepy_Testcase { $c = new Config(); $i = new Input(); - $this->output = new Output($c, $i); + $this->output = new MockOutput($c, $i); } - public function testGetAcceptedType() + public function dataSetData() { - + return [ + 'default mime' => [ + 'accept' => 'text/csv, text/plain;q=0.8, */*;q=0.1', + 'parsed_accept' => [ + '1' => 'text/csv', + '0.8' => 'text/plain', + '0.1' => '*/*' + ], + 'types' => 'json', + 'mapping' => [ + 'text/xml' => 'XML', + 'foo/bar' => 'HTML', + 'application/json' => 'JSON', + '*/*' => 'application/json' + ], + 'expected' => [ + 'application/json' => 'Sleepy\Type\JSON' + ] + ], + 'html' => [ + 'accept' => 'text/html, text/plain;q=0.5', + 'parsed_accept' => [ + '1' => 'text/html', + '0.5' => 'text/plain' + ], + 'types' => ['json', 'xml', 'html'], + 'mapping' => [ + 'text/html' => 'HTML', + 'application/json' => 'JSON', + 'text/plain' => 'YAML' + ], + 'expected' => [ + 'text/html' => 'Sleepy\Type\HTML' + ] + ] + ]; } + /** + * @dataProvider dataSetData + */ + public function testSetData($accept, $parsed_accept, $types, $mapping, $expected) + { + $_SERVER['HTTP_ACCEPT'] = $accept; + + // Recreate input object to use new + // superglobal value + $in = new Input(); + $pheaders = $in->header_array(); + $this->output->setInput($in); + + // Sanity check of accept header + $this->assertEquals($parsed_accept, $pheaders['accept']); + + // Mock config to set the mime/class mapping list + $conf = new MockConfig(); + $conf->setData(['type_class_map' => $mapping]); + $this->output->setConfig($conf); + + $this->output->setData(['foo' => 'bar']); + $res = $this->output->set_data($types); + + $this->assertEquals($expected, $res); + } + public function dataSetHeader() + { + return [ + 'single_header' => [ + 'input' => 'Content-type: text/html', + 'expected' => [ + 'Content-type: text/html' + ] + ], + 'header_array' => [ + 'input' => [ + 'Content-type: text/plain', + 'Accept-encoding' => 'utf-8' + ], + 'expected' => [ + 'Content-type: text/plain', + 'Accept-encoding: utf-8' + ] + ] + ]; + } + + /** + * @dataProvider dataSetHeader + */ + public function testSetHeader($input, $expected) + { + $this->output->set_header($input); + $headers = $this->output->getHeaders(); + + $this->assertEquals($expected, $headers); + } } // End of OutputTest.php \ No newline at end of file