diff options
author | Dan Duvall <dduvall@wikimedia.org> | 2021-11-11 10:46:32 -0800 |
---|---|---|
committer | Dan Duvall <dduvall@wikimedia.org> | 2021-11-15 14:07:59 -0800 |
commit | 9a4af2566438e1b2350a02e37046ee2b67a54d88 (patch) | |
tree | 55b7098a1c52cff72f29001df3021e450dddc808 /includes/Settings | |
parent | 18fddad2ea4dd134738078e88dde8907b12af052 (diff) | |
download | mediawikicore-9a4af2566438e1b2350a02e37046ee2b67a54d88.tar.gz mediawikicore-9a4af2566438e1b2350a02e37046ee2b67a54d88.zip |
Introduced settings sources and formats
A `SettingsSource` is meant to represent any kind of local or remote
store from which settings can be read, be this a local file, remote URL,
database, etc. It is concerned with reading in (and possibly decoding)
settings data, and computing a consistent hash key that may be used in
caching.
A `SettingsFormat` is meant to detect supported file types and/or decode
source contents into settings arrays. As of now, JSON is the only
supported format but others may be implemented.
`FileSource` is the first source implementation, with its default format
being JSON, meant to read settings from local JSON files.
`ArraySource` is mostly useful for testing using array literals.
Refactored `SettingsBuilder` methods to use the new source abstractions.
Bug: T295499
Change-Id: If7869609c4ad1ccd0894d5ba358f885007168972
Diffstat (limited to 'includes/Settings')
-rw-r--r-- | includes/Settings/SettingsBuilder.php | 81 | ||||
-rw-r--r-- | includes/Settings/Source/ArraySource.php | 25 | ||||
-rw-r--r-- | includes/Settings/Source/FileSource.php | 161 | ||||
-rw-r--r-- | includes/Settings/Source/Format/JsonFormat.php | 57 | ||||
-rw-r--r-- | includes/Settings/Source/Format/SettingsFormat.php | 35 | ||||
-rw-r--r-- | includes/Settings/Source/SettingsSource.php | 25 |
6 files changed, 341 insertions, 43 deletions
diff --git a/includes/Settings/SettingsBuilder.php b/includes/Settings/SettingsBuilder.php index 5426220020a1..35cda91a4f1a 100644 --- a/includes/Settings/SettingsBuilder.php +++ b/includes/Settings/SettingsBuilder.php @@ -3,6 +3,9 @@ namespace MediaWiki\Settings; use MediaWiki\Settings\Config\ConfigSink; +use MediaWiki\Settings\Source\ArraySource; +use MediaWiki\Settings\Source\FileSource; +use MediaWiki\Settings\Source\SettingsSource; /** * Utility for loading settings files. @@ -33,30 +36,16 @@ class SettingsBuilder { } /** - * Load settings from a file. + * Load settings from a {@link SettingsSource}. * * @unstable * - * @param string $source + * @param SettingsSource $source * @return $this */ - public function loadFile( string $source ): self { - $newSettings = $this->readSettingsFile( $source ); - - return $this->loadArray( $newSettings ); - } + public function load( SettingsSource $source ): self { + $newSettings = $source->load(); - /** - * Load settings from aa array. - * - * @unstable - * - * @param array $newSettings - * @param string $sourceName - * - * @return $this - */ - public function loadArray( array $newSettings, $sourceName = '<array>' ): self { $this->settings['config'] = array_merge( $this->settings['config'], $newSettings['config'] ?? [] ); @@ -66,7 +55,7 @@ class SettingsBuilder { ); if ( !empty( $schemaOverrides ) ) { throw new SettingsBuilderException( 'Overriding config schema in {source}', [ - 'source' => $sourceName, + 'source' => $source, 'override_keys' => implode( ',', array_keys( $schemaOverrides ) ), ] ); } @@ -77,6 +66,36 @@ class SettingsBuilder { } /** + * Load settings from an array. + * + * @unstable + * + * @param array $newSettings + * + * @return $this + */ + public function loadArray( array $newSettings ): self { + return $this->load( new ArraySource( $newSettings ) ); + } + + /** + * Load settings from a file. + * + * @unstable + * + * @param string $path + * @return $this + */ + public function loadFile( string $path ): self { + // Qualify the path if it isn't already absolute + if ( !preg_match( '!^[a-zA-Z]:\\\\!', $path ) && $path[0] != DIRECTORY_SEPARATOR ) { + $path = $this->baseDir . DIRECTORY_SEPARATOR . $path; + } + + return $this->load( new FileSource( $path ) ); + } + + /** * Apply any settings loaded so far to the runtime environment. * * @unstable @@ -104,28 +123,4 @@ class SettingsBuilder { 'config-schema' => [], ]; } - - /** - * @param string $path - * - * @return array - */ - private function readSettingsFile( string $path ): array { - $fullPath = $this->baseDir . '/' . $path; - - if ( !file_exists( $fullPath ) ) { - throw new SettingsBuilderException( "settings file '{path}' does not exist", [ - 'path' => $fullPath - ] ); - } - - $json = file_get_contents( $fullPath ); - $newSettings = json_decode( $json, true ); - - if ( !is_array( $newSettings ) ) { - throw new SettingsBuilderException( "failed to decode JSON from '$fullPath'" ); - } - - return $newSettings; - } } diff --git a/includes/Settings/Source/ArraySource.php b/includes/Settings/Source/ArraySource.php new file mode 100644 index 000000000000..a7a1cdfbf66f --- /dev/null +++ b/includes/Settings/Source/ArraySource.php @@ -0,0 +1,25 @@ +<?php + +namespace MediaWiki\Settings\Source; + +/** + * Settings loaded from an array. + * + * @since 1.38 + */ +class ArraySource implements SettingsSource { + + private $settings; + + public function __construct( array $settings ) { + $this->settings = $settings; + } + + public function load(): array { + return $this->settings; + } + + public function __toString(): string { + return '<array>'; + } +} diff --git a/includes/Settings/Source/FileSource.php b/includes/Settings/Source/FileSource.php new file mode 100644 index 000000000000..2320f377049b --- /dev/null +++ b/includes/Settings/Source/FileSource.php @@ -0,0 +1,161 @@ +<?php + +namespace MediaWiki\Settings\Source; + +use MediaWiki\Settings\SettingsBuilderException; +use MediaWiki\Settings\Source\Format\JsonFormat; +use MediaWiki\Settings\Source\Format\SettingsFormat; +use UnexpectedValueException; +use Wikimedia\AtEase\AtEase; + +/** + * Settings loaded from a local file path. + * + * @since 1.38 + */ +class FileSource implements SettingsSource { + /** + * Default format with which to attempt decoding if none are given to the + * constructor. + */ + private const DEFAULT_FORMAT = JsonFormat::class; + + /** + * Possible formats. + * @var array + */ + private $formats; + + /** + * Path to local file. + * @var string + */ + private $path; + + /** + * Constructs a new FileSource for the given path and possible matching + * formats. The first format to match the path's file extension will be + * used to decode the content. + * + * An end-user caller may be explicit about the given path's format by + * providing only one format. + * + * <code> + * <?php + * $source = new FileSource( 'my/settings.json', new JsonFormat() ); + * $source->load(); + * </code> + * + * While a generalized caller may want to pass a number of supported + * formats. + * + * <code> + * <?php + * function loadAllPossibleFormats( string $path ) { + * $source = new FileSource( + * $path, + * new JsonFormat(), + * new YamlFormat(), + * new TomlFormat() + * ) + * } + * </code> + * + * @param string $path + * @param SettingsFormat ...$formats + */ + public function __construct( string $path, SettingsFormat ...$formats ) { + $this->path = $path; + $this->formats = $formats; + + if ( empty( $this->formats ) ) { + $class = self::DEFAULT_FORMAT; + $this->formats = [ new $class() ]; + } + } + + /** + * Loads contents from the file and decodes them using the first format + * to claim support for the file's extension. + * + * @throws SettingsBuilderException + * @return array + */ + public function load(): array { + $ext = pathinfo( $this->path, PATHINFO_EXTENSION ); + + // If there's only one format, don't bother to match the file + // extension. + if ( count( $this->formats ) == 1 ) { + return $this->readAndDecode( $this->formats[0] ); + } + + foreach ( $this->formats as $format ) { + if ( $format->supportsFileExtension( $ext ) ) { + return $this->readAndDecode( $format ); + } + } + + throw new SettingsBuilderException( + "None of the given formats ({formats}) are suitable for '{path}'", + [ + 'formats' => implode( ', ', $this->formats ), + 'path' => $this->path, + ] + ); + } + + /** + * Returns this file source as a string. + * + * @return string + */ + public function __toString(): string { + return $this->path; + } + + /** + * Reads and decodes the file contents using the given format. + * + * @param SettingsFormat $format + * + * @return array + * @throws SettingsBuilderException + */ + private function readAndDecode( SettingsFormat $format ): array { + $contents = AtEase::quietCall( 'file_get_contents', $this->path ); + + if ( $contents === false ) { + if ( !is_readable( $this->path ) ) { + throw new SettingsBuilderException( + "File '{path}' is not readable", + [ 'path' => $this->path ] + ); + } + + if ( is_dir( $this->path ) ) { + throw new SettingsBuilderException( + "'{path}' is a directory, not a file", + [ 'path' => $this->path ] + ); + } + + throw new SettingsBuilderException( + "Failed to read file '{path}'", + [ 'path' => $this->path ] + ); + } + + try { + return $format->decode( $contents ); + } catch ( UnexpectedValueException $e ) { + throw new SettingsBuilderException( + "Failed to decode file '{path}': {message}", + [ + 'path' => $this->path, + 'message' => $e->getMessage() + ] + ); + } + } +} diff --git a/includes/Settings/Source/Format/JsonFormat.php b/includes/Settings/Source/Format/JsonFormat.php new file mode 100644 index 000000000000..1151ab93a2cb --- /dev/null +++ b/includes/Settings/Source/Format/JsonFormat.php @@ -0,0 +1,57 @@ +<?php + +namespace MediaWiki\Settings\Source\Format; + +use UnexpectedValueException; + +/** + * Decodes settings data from JSON. + */ +class JsonFormat implements SettingsFormat { + + /** + * Decodes JSON. + * + * @param string $data JSON string to decode. + * + * @return array + * @throws UnexpectedValueException + */ + public function decode( string $data ): array { + $settings = json_decode( $data, true ); + + if ( $settings === null ) { + throw new UnexpectedValueException( + 'Failed to decode JSON: ' . json_last_error_msg() + ); + } + + if ( !is_array( $settings ) ) { + throw new UnexpectedValueException( + 'Decoded settings must be an array' + ); + } + + return $settings; + } + + /** + * Returns true for the file extension 'json'. Case insensitive. + * + * @param string $ext File extension. + * + * @return bool + */ + public function supportsFileExtension( string $ext ): bool { + return strtolower( $ext ) == 'json'; + } + + /** + * Returns the name/type of this format (JSON). + * + * @return string + */ + public function __toString(): string { + return 'JSON'; + } +} diff --git a/includes/Settings/Source/Format/SettingsFormat.php b/includes/Settings/Source/Format/SettingsFormat.php new file mode 100644 index 000000000000..943faa88d544 --- /dev/null +++ b/includes/Settings/Source/Format/SettingsFormat.php @@ -0,0 +1,35 @@ +<?php + +namespace MediaWiki\Settings\Source\Format; + +use Stringable; +use UnexpectedValueException; + +/** + * A SettingsFormat is meant to detect supported file types and/or decode + * source contents into settings arrays. + * + * @since 1.38 + * @todo mark as stable before the 1.38 release + */ +interface SettingsFormat extends Stringable { + /** + * Decodes the given settings data and returns an associative array. + * + * @param string $data Settings data. + * + * @return array + * @throws UnexpectedValueException + */ + public function decode( string $data ): array; + + /** + * Whether or not the format claims to support a file with the given + * extension. + * + * @param string $ext File extension. + * + * @return bool + */ + public function supportsFileExtension( string $ext ): bool; +} diff --git a/includes/Settings/Source/SettingsSource.php b/includes/Settings/Source/SettingsSource.php new file mode 100644 index 000000000000..31df14fefde1 --- /dev/null +++ b/includes/Settings/Source/SettingsSource.php @@ -0,0 +1,25 @@ +<?php + +namespace MediaWiki\Settings\Source; + +use MediaWiki\Settings\SettingsBuilderException; +use Stringable; + +/** + * A SettingsSource is meant to represent any kind of local or remote store + * from which settings can be read, be it a local file, remote URL, database, + * etc. It is concerned with reading (and possibly decoding) settings data. + * + * @since 1.38 + * @todo mark as stable before the 1.38 release + */ +interface SettingsSource extends Stringable { + /** + * Loads and returns all settings from this source as an associative + * array. + * + * @return array + * @throws SettingsBuilderException + */ + public function load(): array; +} |