diff --git a/library/Icinga/Application/Config.php b/library/Icinga/Application/Config.php index c7998a0bb..ccadebace 100644 --- a/library/Icinga/Application/Config.php +++ b/library/Icinga/Application/Config.php @@ -4,14 +4,18 @@ namespace Icinga\Application; -use Zend_Config; -use Zend_Config_Ini; +use Countable; +use ArrayAccess; +use ArrayIterator; +use IteratorAggregate; +use LogicException; +use UnexpectedValueException; use Icinga\Exception\NotReadableError; /** - * Global registry of application and module configuration. + * Container for configuration values and global registry of application and module related configuration. */ -class Config extends Zend_Config +class Config implements Countable, ArrayAccess, IteratorAggregate { /** * Configuration directory where ALL (application and module) configuration is located @@ -20,13 +24,6 @@ class Config extends Zend_Config */ public static $configDir; - /** - * The INI file this configuration has been loaded from or should be written to - * - * @var string - */ - protected $configFile; - /** * Application config instances per file * @@ -42,95 +39,34 @@ class Config extends Zend_Config protected static $modules = array(); /** - * Load configuration from the given INI file + * This config's data * - * @param string $file The file to parse - * - * @throws NotReadableError When the file does not exist or cannot be read + * @var array */ - public static function fromIni($file) - { - $config = new static(array(), true); - $filepath = realpath($file); - - if ($filepath === false) { - $config->setConfigFile($file); - } elseif (is_readable($filepath)) { - $config->setConfigFile($filepath); - $config->merge(new Zend_Config_Ini($filepath)); - } else { - throw new NotReadableError('Cannot read config file "%s". Permission denied', $filepath); - } - - return $config; - } + protected $data; /** - * Retrieve a application config instance + * The INI file this configuration has been loaded from or should be written to * - * @param string $configname The configuration name (without ini suffix) to read and return - * @param bool $fromDisk When set true, the configuration will be read from the disk, even - * if it already has been read - * - * @return Config The configuration object that has been requested + * @var string */ - public static function app($configname = 'config', $fromDisk = false) - { - if (!isset(self::$app[$configname]) || $fromDisk) { - self::$app[$configname] = Config::fromIni(self::resolvePath($configname . '.ini')); - } - return self::$app[$configname]; - } + protected $configFile; /** - * Set module config + * Create a new config * - * @param string $moduleName - * @param string $configName - * @param Zend_Config $config + * @param array $data The data to initialize the new config with */ - public static function setModuleConfig($moduleName, $configName, Zend_Config $config) + public function __construct(array $data = array()) { - self::$modules[$moduleName][$configName] = $config; - } + $this->data = array(); - /** - * Retrieve a module config instance - * - * @param string $modulename The name of the module to look for configurations - * @param string $configname The configuration name (without ini suffix) to read and return - * @param string $fromDisk Whether to read the configuration from disk - * - * @return Config The configuration object that has been requested - */ - public static function module($modulename, $configname = 'config', $fromDisk = false) - { - if (!isset(self::$modules[$modulename])) { - self::$modules[$modulename] = array(); - } - $moduleConfigs = self::$modules[$modulename]; - if (!isset($moduleConfigs[$configname]) || $fromDisk) { - $moduleConfigs[$configname] = Config::fromIni( - self::resolvePath('modules/' . $modulename . '/' . $configname . '.ini') - ); - } - return $moduleConfigs[$configname]; - } - - /** - * Retrieve names of accessible sections or properties - * - * @param $name - * @return array - */ - public function keys($name = null) - { - if ($name === null) { - return array_keys($this->toArray()); - } elseif ($this->$name === null) { - return array(); - } else { - return array_keys($this->$name->toArray()); + foreach ($data as $key => $value) { + if (is_array($value)) { + $this->data[$key] = new static($value); + } else { + $this->data[$key] = $value; + } } } @@ -158,13 +94,335 @@ class Config extends Zend_Config } /** - * Prepend configuration base dir if input is relative + * Deep clone this config + */ + public function __clone() + { + $array = array(); + foreach ($this->data as $key => $value) { + if ($value instanceof self) { + $array[$key] = clone $value; + } else { + $array[$key] = $value; + } + } + + $this->data = $array; + } + + /** + * Return the count of available sections and properties * - * @param string $path Input path - * @return string Absolute path + * @return int + */ + public function count() + { + return count($this->data); + } + + /** + * Return a iterator for this config's data + * + * @return ArrayIterator + */ + public function getIterator() + { + return new ArrayIterator($this->data); + } + + /** + * Return whether the given section or property is set + * + * @param string $key The name of the section or property + * + * @return bool + */ + public function __isset($key) + { + return isset($this->data[$key]); + } + + /** + * Return the value for the given property or the config for the given section + * + * @param string $key The name of the property or section + * + * @return mixed|NULL The value or NULL in case $key does not exist + */ + public function __get($key) + { + if (array_key_exists($key, $this->data)) { + return $this->data[$key]; + } + } + + /** + * Add a new property or section + * + * @param string $key The name of the new property or section + * @param mixed $value The value to set for the new property or section + */ + public function __set($key, $value) + { + if (is_array($value)) { + $this->data[$key] = new static($value); + } else { + $this->data[$key] = $value; + } + } + + /** + * Remove the given property or section + * + * @param string $key The property or section to remove + */ + public function __unset($key) + { + unset($this->data[$key]); + } + + /** + * Return whether the given section or property is set + * + * @param string $key The name of the section or property + * + * @return bool + */ + public function offsetExists($key) + { + return isset($this->$key); + } + + /** + * Return the value for the given property or the config for the given section + * + * @param string $key The name of the property or section + * + * @return mixed|NULL The value or NULL in case $key does not exist + */ + public function offsetGet($key) + { + return $this->$key; + } + + /** + * Add a new property or section + * + * @param string $key The name of the new property or section + * @param mixed $value The value to set for the new property or section + */ + public function offsetSet($key, $value) + { + if ($key === null) { + throw new LogicException('Appending values without an explicit key is not supported'); + } + + $this->$key = $value; + } + + /** + * Remove the given property or section + * + * @param string $key The property or section to remove + */ + public function offsetUnset($key) + { + unset($this->$key); + } + + /** + * Return whether this config has any data + * + * @return bool + */ + public function isEmpty() + { + return $this->count() === 0; + } + + /** + * Return the value for the given property or the config for the given section + * + * @param string $key The name of the property or section + * @param mixed $default The value to return in case the property or section is missing + * + * @return mixed + */ + public function get($key, $default = null) + { + $value = $this->$key; + if ($default !== null && $value === null) { + $value = $default; + } + + return $value; + } + + /** + * Return all section and property names + * + * @return array + */ + public function keys() + { + return array_keys($this->data); + } + + /** + * Return this config's data as associative array + * + * @return array + */ + public function toArray() + { + $array = array(); + foreach ($this->data as $key => $value) { + if ($value instanceof self) { + $array[$key] = $value->toArray(); + } else { + $array[$key] = $value; + } + } + + return $array; + } + + /** + * Merge the given data with this config + * + * @param array|Config $data An array or a config + * + * @return self + */ + public function merge($data) + { + if ($data instanceof self) { + $data = $data->toArray(); + } + + foreach ($data as $key => $value) { + if (array_key_exists($key, $this->data)) { + if (is_array($value)) { + if ($this->data[$key] instanceof self) { + $this->data[$key]->merge($value); + } else { + $this->data[$key] = new static($value); + } + } else { + $this->data[$key] = $value; + } + } else { + $this->data[$key] = is_array($value) ? new static($value) : $value; + } + } + } + + /** + * Return the value from a section's property + * + * @param string $section The section where the given property can be found + * @param string $key The section's property to fetch the value from + * @param mixed $default The value to return in case the section or the property is missing + * + * @return mixed + * + * @throws UnexpectedValueException In case the given section does not hold any configuration + */ + public function fromSection($section, $key, $default = null) + { + $value = $this->$section; + if ($value instanceof self) { + $value = $value->$key; + } elseif ($value !== null) { + throw new UnexpectedValueException( + sprintf('Value "%s" is not of type "Config" or a sub-type of it', $value) + ); + } + + if ($default !== null) { + $value = $default; + } + + return $value; + } + + /** + * Load configuration from the given INI file + * + * @param string $file The file to parse + * + * @throws NotReadableError When the file does not exist or cannot be read + */ + public static function fromIni($file) + { + $config = new static(); + + $filepath = realpath($file); + if ($filepath === false) { + $config->setConfigFile($file); + } elseif (is_readable($filepath)) { + $config->setConfigFile($filepath); + $config->merge(parse_ini_file($filepath, true, INI_SCANNER_RAW)); + } else { + throw new NotReadableError(t('Cannot read config file "%s". Permission denied'), $filepath); + } + + return $config; + } + + /** + * Prepend configuration base dir to the given relative path + * + * @param string $path A relative path + * + * @return string */ public static function resolvePath($path) { return self::$configDir . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR); } + + /** + * Retrieve a application config + * + * @param string $configname The configuration name (without ini suffix) to read and return + * @param bool $fromDisk When set true, the configuration will be read from disk, even + * if it already has been read + * + * @return Config The requested configuration + */ + public static function app($configname = 'config', $fromDisk = false) + { + if (!isset(self::$app[$configname]) || $fromDisk) { + self::$app[$configname] = static::fromIni(static::resolvePath($configname . '.ini')); + } + + return self::$app[$configname]; + } + + /** + * Retrieve a module config + * + * @param string $modulename The name of the module where to look for the requested configuration + * @param string $configname The configuration name (without ini suffix) to read and return + * @param string $fromDisk When set true, the configuration will be read from disk, even + * if it already has been read + * + * @return Config The requested configuration + */ + public static function module($modulename, $configname = 'config', $fromDisk = false) + { + if (!isset(self::$modules[$modulename])) { + self::$modules[$modulename] = array(); + } + + $moduleConfigs = self::$modules[$modulename]; + if (!isset($moduleConfigs[$configname]) || $fromDisk) { + $moduleConfigs[$configname] = static::fromIni( + static::resolvePath('modules/' . $modulename . '/' . $configname . '.ini') + ); + } + + return $moduleConfigs[$configname]; + } } diff --git a/test/php/library/Icinga/Application/ConfigTest.php b/test/php/library/Icinga/Application/ConfigTest.php index e99b17b2e..77627dcdb 100644 --- a/test/php/library/Icinga/Application/ConfigTest.php +++ b/test/php/library/Icinga/Application/ConfigTest.php @@ -5,7 +5,7 @@ namespace Tests\Icinga\Application; use Icinga\Test\BaseTestCase; -use Icinga\Application\Config as IcingaConfig; +use Icinga\Application\Config; class ConfigTest extends BaseTestCase { @@ -15,8 +15,8 @@ class ConfigTest extends BaseTestCase public function setUp() { parent::setUp(); - $this->configDir = IcingaConfig::$configDir; - IcingaConfig::$configDir = dirname(__FILE__) . '/ConfigTest/files'; + $this->oldConfigDir = Config::$configDir; + Config::$configDir = dirname(__FILE__) . '/ConfigTest/files'; } /** @@ -25,77 +25,327 @@ class ConfigTest extends BaseTestCase public function tearDown() { parent::tearDown(); - IcingaConfig::$configDir = $this->configDir; + Config::$configDir = $this->oldConfigDir; } - public function testAppConfig() + public function testWhetherInitializingAConfigWithAssociativeArraysCreatesHierarchicalConfigObjects() { - $config = IcingaConfig::app('config', true); - $this->assertEquals(1, $config->logging->enable, 'Unexpected value retrieved from config file'); - // Test non-existent property where null is the default value - $this->assertEquals( - null, - $config->logging->get('disable'), - 'Unexpected default value for non-existent properties' + $config = new Config(array( + 'a' => 'b', + 'c' => 'd', + 'e' => array( + 'f' => 'g', + 'h' => 'i', + 'j' => array( + 'k' => 'l', + 'm' => 'n' + ) + ) + )); + + $this->assertInstanceOf( + get_class($config), + $config->e, + 'Config::__construct() does not accept two dimensional arrays' ); - // Test non-existent property using zero as the default value - $this->assertEquals(0, $config->logging->get('disable', 0)); - // Test retrieve full section + $this->assertInstanceOf( + get_class($config), + $config->e->j, + 'Config::__construct() does not accept multi dimensional arrays' + ); + } + + /** + * @depends testWhetherInitializingAConfigWithAssociativeArraysCreatesHierarchicalConfigObjects + */ + public function testWhetherItIsPossibleToCloneConfigObjects() + { + $config = new Config(array( + 'a' => 'b', + 'c' => array( + 'd' => 'e' + ) + )); + $newConfig = clone $config; + + $this->assertNotSame( + $config, + $newConfig, + 'Shallow cloning objects of type Config does not seem to work properly' + ); + $this->assertNotSame( + $config->c, + $newConfig->c, + 'Deep cloning objects of type Config does not seem to work properly' + ); + } + + public function testWhetherConfigObjectsAreCountable() + { + $config = new Config(array('a' => 'b', 'c' => array('d' => 'e'))); + + $this->assertInstanceOf('Countable', $config, 'Config objects do not implement interface `Countable\''); + $this->assertEquals(2, $config->count(), 'Config objects do not count properties and sections correctly'); + } + + public function testWhetherOneCanCheckWhetherConfigObjectsHaveACertainPropertyOrSection() + { + $config = new Config(array('a' => 'b', 'c' => array('d' => 'e'))); + + $this->assertTrue(isset($config->a), 'Config objects do not seem to implement __isset() properly'); + $this->assertTrue(isset($config->c->d), 'Config objects do not seem to implement __isset() properly'); + $this->assertFalse(isset($config->d), 'Config objects do not seem to implement __isset() properly'); + $this->assertFalse(isset($config->c->e), 'Config objects do not seem to implement __isset() properly'); + $this->assertTrue(isset($config['a']), 'Config object do not seem to implement offsetExists() properly'); + $this->assertFalse(isset($config['d']), 'Config object do not seem to implement offsetExists() properly'); + } + + public function testWhetherItIsPossibleToAccessProperties() + { + $config = new Config(array('a' => 'b', 'c' => null)); + + $this->assertEquals('b', $config->a, 'Config objects do not allow property access'); + $this->assertNull($config['c'], 'Config objects do not allow offset access'); + $this->assertNull($config->d, 'Config objects do not return NULL as default'); + } + + public function testWhetherItIsPossibleToSetPropertiesAndSections() + { + $config = new Config(); + $config->a = 'b'; + $config['c'] = array('d' => 'e'); + + $this->assertTrue(isset($config->a), 'Config objects do not allow to set properties'); + $this->assertTrue(isset($config->c), 'Config objects do not allow to set offsets'); + $this->assertInstanceOf( + get_class($config), + $config->c, + 'Config objects do not convert arrays to config objects when set' + ); + } + + /** + * @expectedException LogicException + */ + public function testWhetherItIsNotPossibleToAppendProperties() + { + $config = new Config(); + $config[] = 'test'; + } + + public function testWhetherItIsPossibleToUnsetPropertiesAndSections() + { + $config = new Config(array('a' => 'b', 'c' => array('d' => 'e'))); + unset($config->a); + unset($config['c']); + + $this->assertFalse(isset($config->a), 'Config objects do not allow to unset properties'); + $this->assertFalse(isset($config->c), 'Config objects do not allow to unset sections'); + } + + /** + * @depends testWhetherConfigObjectsAreCountable + */ + public function testWhetherOneCanCheckIfAConfigObjectHasAnyPropertiesOrSections() + { + $config = new Config(); + $this->assertTrue($config->isEmpty(), 'Config objects do not report that they are empty'); + + $config->test = 'test'; + $this->assertFalse($config->isEmpty(), 'Config objects do report that they are empty although they are not'); + } + + /** + * @depends testWhetherItIsPossibleToAccessProperties + */ + public function testWhetherItIsPossibleToRetrieveDefaultValuesForNonExistentPropertiesOrSections() + { + $config = new Config(array('a' => 'b')); + + $this->assertEquals( + 'b', + $config->get('a'), + 'Config objects do not return the actual value of existing properties' + ); + $this->assertNull( + $config->get('b'), + 'Config objects do not return NULL as default for non-existent properties' + ); + $this->assertEquals( + 'test', + $config->get('test', 'test'), + 'Config objects do not allow to define the default value to return for non-existent properties' + ); + } + + public function testWhetherItIsPossibleToRetrieveAllPropertyAndSectionNames() + { + $config = new Config(array('a' => 'b', 'c' => array('d' => 'e'))); + + $this->assertEquals( + array('a', 'c'), + $config->keys(), + 'Config objects do not list property and section names correctly' + ); + } + + public function testWhetherConfigObjectsCanBeConvertedToArrays() + { + $config = new Config(array('a' => 'b', 'c' => array('d' => 'e'))); + + $this->assertEquals( + array('a' => 'b', 'c' => array('d' => 'e')), + $config->toArray(), + 'Config objects cannot be correctly converted to arrays' + ); + } + + /** + * @depends testWhetherConfigObjectsCanBeConvertedToArrays + */ + public function testWhetherItIsPossibleToMergeConfigObjects() + { + $config = new Config(array('a' => 'b')); + + $config->merge(array('a' => 'bb', 'c' => 'd', 'e' => array('f' => 'g'))); + $this->assertEquals( + array('a' => 'bb', 'c' => 'd', 'e' => array('f' => 'g')), + $config->toArray(), + 'Config objects cannot be extended with arrays' + ); + + $config->merge(new Config(array('c' => array('d' => 'ee'), 'e' => array('h' => 'i')))); + $this->assertEquals( + array('a' => 'bb', 'c' => array('d' => 'ee'), 'e' => array('f' => 'g', 'h' => 'i')), + $config->toArray(), + 'Config objects cannot be extended with other Config objects' + ); + } + + /** + * @depends testWhetherItIsPossibleToAccessProperties + */ + public function testWhetherItIsPossibleToDirectlyRetrieveASectionProperty() + { + $config = new Config(array('a' => array('b' => 'c'))); + + $this->assertEquals( + 'c', + $config->fromSection('a', 'b'), + 'Config::fromSection does not return the actual value of a section\'s property' + ); + $this->assertNull( + $config->fromSection('a', 'c'), + 'Config::fromSection does not return NULL as default for non-existent section properties' + ); + $this->assertNull( + $config->fromSection('b', 'c'), + 'Config::fromSection does not return NULL as default for non-existent sections' + ); + $this->assertEquals( + 'test', + $config->fromSection('a', 'c', 'test'), + 'Config::fromSection does not return the given default value for non-existent section properties' + ); + } + + /** + * @expectedException UnexpectedValueException + * @depends testWhetherItIsPossibleToAccessProperties + */ + public function testWhetherAnExceptionIsThrownWhenTryingToAccessASectionPropertyOnANonSection() + { + $config = new Config(array('a' => 'b')); + $config->fromSection('a', 'b'); + } + + public function testWhetherConfigResolvePathReturnsValidAbsolutePaths() + { + $this->assertEquals( + Config::$configDir . DIRECTORY_SEPARATOR . 'a' . DIRECTORY_SEPARATOR . 'b.ini', + Config::resolvePath(DIRECTORY_SEPARATOR . 'a' . DIRECTORY_SEPARATOR . 'b.ini'), + 'Config::resolvePath does not produce valid absolute paths' + ); + } + + /** + * @depends testWhetherConfigObjectsCanBeConvertedToArrays + * @depends testWhetherConfigResolvePathReturnsValidAbsolutePaths + */ + public function testWhetherItIsPossibleToInitializeAConfigObjectFromAIniFile() + { + $config = Config::fromIni(Config::resolvePath('config.ini')); + $this->assertEquals( array( - 'disable' => 1, - 'db' => array( - 'user' => 'user', - 'password' => 'password' + 'logging' => array( + 'enable' => 1 + ), + 'backend' => array( + 'type' => 'db', + 'user' => 'user', + 'password' => 'password', + 'disable' => 1 ) ), - $config->backend->toArray() + $config->toArray(), + 'Config::fromIni does not load INI files correctly' ); - // Test non-existent section using 'default' as default value - $this->assertEquals('default', $config->get('magic', 'default')); - // Test sub-properties - $this->assertEquals('user', $config->backend->db->user); - // Test non-existent sub-property using 'UTF-8' as the default value - $this->assertEquals('UTF-8', $config->backend->db->get('encoding', 'UTF-8')); - // Test invalid property names using false as default value - $this->assertEquals(false, $config->backend->get('.', false)); - $this->assertEquals(false, $config->backend->get('db.', false)); - $this->assertEquals(false, $config->backend->get('.user', false)); - // Test retrieve array of sub-properties + + $this->assertInstanceOf( + get_class($config), + Config::fromIni('nichda'), + 'Config::fromIni does not return empty configs for non-existent configuration files' + ); + } + + /** + * @expectedException Icinga\Exception\NotReadableError + */ + public function testWhetherFromIniThrowsAnExceptionOnInsufficientPermission() + { + Config::fromIni('/etc/shadow'); + } + + /** + * @depends testWhetherItIsPossibleToInitializeAConfigObjectFromAIniFile + */ + public function testWhetherItIsPossibleToRetrieveApplicationConfiguration() + { + $config = Config::app(); + $this->assertEquals( array( - 'user' => 'user', - 'password' => 'password' + 'logging' => array( + 'enable' => 1 + ), + 'backend' => array( + 'type' => 'db', + 'user' => 'user', + 'password' => 'password', + 'disable' => 1 + ) ), - $config->backend->db->toArray() + $config->toArray(), + 'Config::app does not load INI files correctly' ); - // Test singleton - $this->assertEquals($config, IcingaConfig::app('config')); - $this->assertEquals(array('logging', 'backend'), $config->keys()); - $this->assertEquals(array('enable'), $config->keys('logging')); } - public function testAppExtraConfig() + /** + * @depends testWhetherItIsPossibleToInitializeAConfigObjectFromAIniFile + */ + public function testWhetherItIsPossibleToRetrieveModuleConfiguration() { - $extraConfig = IcingaConfig::app('extra', true); - $this->assertEquals(1, $extraConfig->meta->version); - $this->assertEquals($extraConfig, IcingaConfig::app('extra')); - } + $config = Config::module('amodule'); - public function testModuleConfig() - { - $moduleConfig = IcingaConfig::module('amodule', 'config', true); - $this->assertEquals(1, $moduleConfig->menu->get('breadcrumb')); - $this->assertEquals($moduleConfig, IcingaConfig::module('amodule')); - } - - public function testModuleExtraConfig() - { - $moduleExtraConfig = IcingaConfig::module('amodule', 'extra', true); $this->assertEquals( - 'inetOrgPerson', - $moduleExtraConfig->ldap->user->get('ldap_object_class') + array( + 'menu' => array( + 'breadcrumb' => 1 + ) + ), + $config->toArray(), + 'Config::module does not load INI files correctly' ); - $this->assertEquals($moduleExtraConfig, IcingaConfig::module('amodule', 'extra')); } } diff --git a/test/php/library/Icinga/Application/ConfigTest/files/config.ini b/test/php/library/Icinga/Application/ConfigTest/files/config.ini index b74ea65c5..aa729ae58 100644 --- a/test/php/library/Icinga/Application/ConfigTest/files/config.ini +++ b/test/php/library/Icinga/Application/ConfigTest/files/config.ini @@ -2,6 +2,7 @@ enable = 1 [backend] -db.user = 'user' -db.password = 'password' +type = "db" +user = "user" +password = "password" disable = 1 diff --git a/test/php/library/Icinga/Application/ConfigTest/files/extra.ini b/test/php/library/Icinga/Application/ConfigTest/files/extra.ini deleted file mode 100644 index fc203052e..000000000 --- a/test/php/library/Icinga/Application/ConfigTest/files/extra.ini +++ /dev/null @@ -1,2 +0,0 @@ -[meta] -version = 1 diff --git a/test/php/library/Icinga/Application/ConfigTest/files/modules/amodule/extra.ini b/test/php/library/Icinga/Application/ConfigTest/files/modules/amodule/extra.ini deleted file mode 100644 index 38fd04b27..000000000 --- a/test/php/library/Icinga/Application/ConfigTest/files/modules/amodule/extra.ini +++ /dev/null @@ -1,2 +0,0 @@ -[ldap] -user.ldap_object_class = inetOrgPerson