Merge "SettingsBuilder: Add YAML file format."

This commit is contained in:
jenkins-bot 2021-11-29 20:51:49 +00:00 committed by Gerrit Code Review
commit e00f24a11e
7 changed files with 259 additions and 51 deletions

View file

@ -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",

View file

@ -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(),
* '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,
]
);

View file

@ -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';
}

View file

@ -31,5 +31,5 @@ interface SettingsFormat extends Stringable {
*
* @return bool
*/
public function supportsFileExtension( string $ext ): bool;
public static function supportsFileExtension( string $ext ): bool;
}

View 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';
}
}

View file

@ -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 ) );
}
}

View file

@ -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 ) );
}
}