Merge "SettingsBuilder: Add YAML file format."
This commit is contained in:
commit
e00f24a11e
7 changed files with 259 additions and 51 deletions
|
|
@ -45,6 +45,7 @@
|
|||
"psr/log": "1.1.4",
|
||||
"ralouphie/getallheaders": "3.0.3",
|
||||
"symfony/polyfill-php80": "1.23.1",
|
||||
"symfony/yaml": "5.3.6",
|
||||
"wikimedia/assert": "0.5.0",
|
||||
"wikimedia/at-ease": "2.1.0",
|
||||
"wikimedia/base-convert": "2.0.1",
|
||||
|
|
@ -93,7 +94,6 @@
|
|||
"phpunit/phpunit": "^8.5",
|
||||
"psy/psysh": "^0.10.5",
|
||||
"seld/jsonlint": "1.8.3",
|
||||
"symfony/yaml": "~3.4|~5.1",
|
||||
"wikimedia/testing-access-wrapper": "~2.0",
|
||||
"wmde/hamcrest-html-matchers": "^1.0.0"
|
||||
},
|
||||
|
|
@ -104,6 +104,7 @@
|
|||
"suggest": {
|
||||
"ext-apcu": "Local data cache for greatly improved performance",
|
||||
"ext-curl": "Improved http communication abilities",
|
||||
"ext-yaml": "Improved YAML parsing performance",
|
||||
"ext-openssl": "Cryptographical functions",
|
||||
"ext-wikidiff2": "Diff accelerator",
|
||||
"monolog/monolog": "Flexible debug logging system",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace MediaWiki\Settings\Source;
|
|||
use MediaWiki\Settings\SettingsBuilderException;
|
||||
use MediaWiki\Settings\Source\Format\JsonFormat;
|
||||
use MediaWiki\Settings\Source\Format\SettingsFormat;
|
||||
use MediaWiki\Settings\Source\Format\YamlFormat;
|
||||
use UnexpectedValueException;
|
||||
use Wikimedia\AtEase\AtEase;
|
||||
|
||||
|
|
@ -14,17 +15,18 @@ use Wikimedia\AtEase\AtEase;
|
|||
* @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;
|
||||
|
||||
private const BUILT_IN_FORMATS = [
|
||||
JsonFormat::class,
|
||||
YamlFormat::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Possible formats.
|
||||
* @var array
|
||||
* Format to use for reading the file, if given.
|
||||
*
|
||||
* @var ?SettingsFormat
|
||||
*/
|
||||
private $formats;
|
||||
private $format;
|
||||
|
||||
/**
|
||||
* Path to local file.
|
||||
|
|
@ -33,45 +35,37 @@ class FileSource implements SettingsSource {
|
|||
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.
|
||||
* Constructs a new FileSource for the given path and possibly a custom format
|
||||
* to decode the contents. If no format is given, the built-in formats will be
|
||||
* tried and the first one that supports the file extension will be used.
|
||||
*
|
||||
* An end-user caller may be explicit about the given path's format by
|
||||
* providing only one format.
|
||||
* Built-in formats:
|
||||
* - JsonFormat
|
||||
* - YamlFormat
|
||||
*
|
||||
* <code>
|
||||
* <?php
|
||||
* $source = new FileSource( 'my/settings.json', new JsonFormat() );
|
||||
* $source = new FileSource( 'my/settings.json' );
|
||||
* $source->load();
|
||||
* </code>
|
||||
*
|
||||
* While a generalized caller may want to pass a number of supported
|
||||
* formats.
|
||||
* While a specialized caller may want to pass a specialized format
|
||||
*
|
||||
* <code>
|
||||
* <?php
|
||||
* function loadAllPossibleFormats( string $path ) {
|
||||
* $source = new FileSource(
|
||||
* $path,
|
||||
* new JsonFormat(),
|
||||
* new YamlFormat(),
|
||||
* new TomlFormat()
|
||||
* )
|
||||
* }
|
||||
* $source = new FileSource(
|
||||
* 'my/settings.toml',
|
||||
* new TomlFormat()
|
||||
* );
|
||||
* $source->load();
|
||||
* </code>
|
||||
*
|
||||
* @param string $path
|
||||
* @param SettingsFormat ...$formats
|
||||
* @param SettingsFormat|null $format
|
||||
*/
|
||||
public function __construct( string $path, SettingsFormat ...$formats ) {
|
||||
public function __construct( string $path, SettingsFormat $format = null ) {
|
||||
$this->path = $path;
|
||||
$this->formats = $formats;
|
||||
|
||||
if ( empty( $this->formats ) ) {
|
||||
$class = self::DEFAULT_FORMAT;
|
||||
$this->formats = [ new $class() ];
|
||||
}
|
||||
$this->format = $format;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -86,20 +80,19 @@ class FileSource implements SettingsSource {
|
|||
|
||||
// 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] );
|
||||
if ( $this->format ) {
|
||||
return $this->readAndDecode( $this->format );
|
||||
}
|
||||
|
||||
foreach ( $this->formats as $format ) {
|
||||
if ( $format->supportsFileExtension( $ext ) ) {
|
||||
return $this->readAndDecode( $format );
|
||||
foreach ( self::BUILT_IN_FORMATS as $format ) {
|
||||
if ( call_user_func( [ $format, 'supportsFileExtension' ], $ext ) ) {
|
||||
return $this->readAndDecode( new $format() );
|
||||
}
|
||||
}
|
||||
|
||||
throw new SettingsBuilderException(
|
||||
"None of the given formats ({formats}) are suitable for '{path}'",
|
||||
"None of the built-in formats are suitable for '{path}'",
|
||||
[
|
||||
'formats' => implode( ', ', $this->formats ),
|
||||
'path' => $this->path,
|
||||
]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class JsonFormat implements SettingsFormat {
|
|||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function supportsFileExtension( string $ext ): bool {
|
||||
public static function supportsFileExtension( string $ext ): bool {
|
||||
return strtolower( $ext ) == 'json';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,5 +31,5 @@ interface SettingsFormat extends Stringable {
|
|||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function supportsFileExtension( string $ext ): bool;
|
||||
public static function supportsFileExtension( string $ext ): bool;
|
||||
}
|
||||
|
|
|
|||
119
includes/Settings/Source/Format/YamlFormat.php
Normal file
119
includes/Settings/Source/Format/YamlFormat.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
namespace MediaWiki\Settings\Source\Format;
|
||||
|
||||
use LogicException;
|
||||
use Symfony\Component\Yaml\Exception\ParseException;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use UnexpectedValueException;
|
||||
use Wikimedia\AtEase\AtEase;
|
||||
|
||||
class YamlFormat implements SettingsFormat {
|
||||
|
||||
public const PARSER_PHP_YAML = 'php-yaml';
|
||||
|
||||
public const PARSER_SYMFONY = 'symfony';
|
||||
|
||||
/** @var string[] */
|
||||
private $useParsers;
|
||||
|
||||
/**
|
||||
* @param string[] $useParsers which parsers to try in order.
|
||||
*/
|
||||
public function __construct( array $useParsers = [ self::PARSER_PHP_YAML, self::PARSER_SYMFONY ] ) {
|
||||
$this->useParsers = $useParsers;
|
||||
}
|
||||
|
||||
public function decode( string $data ): array {
|
||||
foreach ( $this->useParsers as $parser ) {
|
||||
if ( self::isParserAvailable( $parser ) ) {
|
||||
return $this->parseWith( $parser, $data );
|
||||
}
|
||||
}
|
||||
throw new LogicException( 'No parser available' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a specific YAML parser is available.
|
||||
*
|
||||
* @param string $parser one of the PARSER_* constants.
|
||||
* @return bool
|
||||
*/
|
||||
public static function isParserAvailable( string $parser ): bool {
|
||||
switch ( $parser ) {
|
||||
case self::PARSER_PHP_YAML:
|
||||
return function_exists( 'yaml_parse' );
|
||||
case self::PARSER_SYMFONY:
|
||||
return true;
|
||||
default:
|
||||
throw new LogicException( 'Unknown parser: ' . $parser );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $parser
|
||||
* @param string $data
|
||||
* @return array
|
||||
*/
|
||||
private function parseWith( string $parser, string $data ): array {
|
||||
switch ( $parser ) {
|
||||
case self::PARSER_PHP_YAML:
|
||||
return $this->parseWithPhp( $data );
|
||||
case self::PARSER_SYMFONY:
|
||||
return $this->parseWithSymfony( $data );
|
||||
default:
|
||||
throw new LogicException( 'Unknown parser: ' . $parser );
|
||||
}
|
||||
}
|
||||
|
||||
private function parseWithPhp( string $data ): array {
|
||||
$previousValue = ini_set( 'yaml.decode_php', false );
|
||||
try {
|
||||
$ndocs = 0;
|
||||
$result = AtEase::quietCall(
|
||||
'yaml_parse',
|
||||
$data,
|
||||
0,
|
||||
$ndocs,
|
||||
[
|
||||
/**
|
||||
* Crash if provided YAML has PHP constants in it.
|
||||
* We do not want to support that.
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
'!php/const' => static function () {
|
||||
throw new UnexpectedValueException(
|
||||
'PHP constants are not supported'
|
||||
);
|
||||
},
|
||||
]
|
||||
);
|
||||
if ( $result === false ) {
|
||||
throw new UnexpectedValueException( 'Failed to parse YAML' );
|
||||
}
|
||||
return $result;
|
||||
} finally {
|
||||
ini_set( 'yaml.decode_php', $previousValue );
|
||||
}
|
||||
}
|
||||
|
||||
private function parseWithSymfony( string $data ): array {
|
||||
try {
|
||||
return Yaml::parse( $data, Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE );
|
||||
} catch ( ParseException $e ) {
|
||||
throw new UnexpectedValueException(
|
||||
'Failed to parse YAML ' . $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function supportsFileExtension( string $ext ): bool {
|
||||
$ext = strtolower( $ext );
|
||||
return $ext === 'yml' || $ext === 'yaml';
|
||||
}
|
||||
|
||||
public function __toString() {
|
||||
return 'YAML';
|
||||
}
|
||||
}
|
||||
|
|
@ -26,16 +26,16 @@ class JsonFormatTest extends TestCase {
|
|||
$format->decode( '{ bad }' );
|
||||
}
|
||||
|
||||
public function testSupportsFileExtension() {
|
||||
$format = new JsonFormat();
|
||||
|
||||
$this->assertTrue( $format->supportsFileExtension( 'json' ) );
|
||||
$this->assertTrue( $format->supportsFileExtension( 'JSON' ) );
|
||||
public function provideSupportsFileExtension() {
|
||||
yield 'Supported' => [ 'json', true ];
|
||||
yield 'Supported, uppercase' => [ 'JSON', true ];
|
||||
yield 'Unsupported' => [ 'txt', false ];
|
||||
}
|
||||
|
||||
public function testSupportsFileExtensionUnsupported() {
|
||||
$format = new JsonFormat();
|
||||
|
||||
$this->assertFalse( $format->supportsFileExtension( 'yaml' ) );
|
||||
/**
|
||||
* @dataProvider provideSupportsFileExtension
|
||||
*/
|
||||
public function testSupportsFileExtension( $extension, $expected ) {
|
||||
$this->assertSame( $expected, JsonFormat::supportsFileExtension( $extension ) );
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace MediaWiki\Tests\Unit\Settings\Source\Format;
|
||||
|
||||
use MediaWiki\Settings\Source\Format\YamlFormat;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use UnexpectedValueException;
|
||||
|
||||
/** @covers \MediaWiki\Settings\Source\Format\YamlFormat */
|
||||
class YamlFormatTest extends TestCase {
|
||||
|
||||
private const VALID_YAML = <<<'VALID_YAML'
|
||||
config-schema:
|
||||
MySetting:
|
||||
type: boolean
|
||||
VALID_YAML;
|
||||
|
||||
private const INVALID_YAML = <<<'INVALID_YAML'
|
||||
config-schema: []
|
||||
MySetting: []
|
||||
INVALID_YAML;
|
||||
|
||||
public function provideParser() {
|
||||
yield 'php-yaml' => [ 'parser' => YamlFormat::PARSER_PHP_YAML ];
|
||||
yield 'symfony' => [ 'parser' => YamlFormat::PARSER_SYMFONY ];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideParser
|
||||
*/
|
||||
public function testDecode( string $parser ) {
|
||||
if ( !YamlFormat::isParserAvailable( $parser ) ) {
|
||||
$this->markTestSkipped( "Parser '$parser' is not available" );
|
||||
}
|
||||
|
||||
$format = new YamlFormat( [ $parser ] );
|
||||
$this->assertEquals(
|
||||
[ 'config-schema' => [ 'MySetting' => [ 'type' => 'boolean' ] ] ],
|
||||
$format->decode( self::VALID_YAML )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideParser
|
||||
*/
|
||||
public function testDecodeInvalid( string $parser ) {
|
||||
if ( !YamlFormat::isParserAvailable( $parser ) ) {
|
||||
$this->markTestSkipped( "Parser '$parser' is not available" );
|
||||
}
|
||||
|
||||
$format = new YamlFormat( [ $parser ] );
|
||||
$this->expectException( UnexpectedValueException::class );
|
||||
$format->decode( self::INVALID_YAML );
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideParser
|
||||
*/
|
||||
public function testDecodeDoesNotUnserializeObjects( string $parser ) {
|
||||
if ( !YamlFormat::isParserAvailable( $parser ) ) {
|
||||
$this->markTestSkipped( "Parser '$parser' is not available" );
|
||||
}
|
||||
|
||||
$format = new YamlFormat( [ $parser ] );
|
||||
$this->expectException( UnexpectedValueException::class );
|
||||
$format->decode( 'object: !php/object "' . serialize( $format ) . '"' );
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideParser
|
||||
*/
|
||||
public function testDecodeDoesNotRespectPHPConst( string $parser ) {
|
||||
if ( !YamlFormat::isParserAvailable( $parser ) ) {
|
||||
$this->markTestSkipped( "Parser '$parser' is not available" );
|
||||
}
|
||||
|
||||
$format = new YamlFormat( [ $parser ] );
|
||||
$this->expectException( UnexpectedValueException::class );
|
||||
$format->decode( '{ bar: !php/const PHP_INT_SIZE }' );
|
||||
}
|
||||
|
||||
public function provideSupportsFileExtension() {
|
||||
yield 'Supported' => [ 'yaml', true ];
|
||||
yield 'Supported, uppercase' => [ 'YAML', true ];
|
||||
yield 'Supported, short' => [ 'yml', true ];
|
||||
yield 'Unsupported' => [ 'txt', false ];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideSupportsFileExtension
|
||||
*/
|
||||
public function testSupportsFileExtension( $extension, $expected ) {
|
||||
$this->assertSame( $expected, YamlFormat::supportsFileExtension( $extension ) );
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue