From 73e3fd89aa9b8a1a88c8d395ba61f7431467fec3 Mon Sep 17 00:00:00 2001 From: valtzu Date: Sun, 26 Jan 2025 02:28:58 +0200 Subject: [PATCH] Generate JSON schema for config --- .../Bundle/FrameworkBundle/CHANGELOG.md | 1 + .../CacheWarmer/ConfigSchemaCacheWarmer.php | 109 ++++ .../Resources/config/services.php | 5 + src/Symfony/Component/Config/CHANGELOG.md | 1 + .../Component/Config/Definition/BaseNode.php | 8 + .../Config/Definition/BooleanNode.php | 8 + .../Config/Definition/JsonSchemaGenerator.php | 238 +++++++++ .../Config/Definition/NumericNode.php | 16 + .../Config/Definition/PrototypedArrayNode.php | 8 + .../Config/Definition/VariableNode.php | 8 + .../Dumper/YamlReferenceDumperTest.php | 70 +-- .../Definition/JsonSchemaGeneratorTest.php | 44 ++ .../ExampleConfiguration.schema.json | 478 ++++++++++++++++++ .../Configuration/ExampleConfiguration.yaml | 65 +++ 14 files changed, 991 insertions(+), 68 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/CacheWarmer/ConfigSchemaCacheWarmer.php create mode 100644 src/Symfony/Component/Config/Definition/JsonSchemaGenerator.php create mode 100644 src/Symfony/Component/Config/Tests/Definition/JsonSchemaGeneratorTest.php create mode 100644 src/Symfony/Component/Config/Tests/Fixtures/Configuration/ExampleConfiguration.schema.json create mode 100644 src/Symfony/Component/Config/Tests/Fixtures/Configuration/ExampleConfiguration.yaml diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 55eeaf1fdc1f3..c48b5eb4492bf 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -12,6 +12,7 @@ CHANGELOG * Add `RateLimiterFactoryInterface` as an alias of the `limiter` service * Add `framework.validation.disable_translation` option * Add support for signal plain name in the `messenger.stop_worker_on_signals` configuration + * Generate JSON schema for YAML configuration 7.2 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/ConfigSchemaCacheWarmer.php b/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/ConfigSchemaCacheWarmer.php new file mode 100644 index 0000000000000..53c258d5e81b3 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/ConfigSchemaCacheWarmer.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\CacheWarmer; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Config\Definition\ArrayNode; +use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\Config\Definition\JsonSchemaGenerator; +use Symfony\Component\DependencyInjection\Container; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\ConfigurationExtensionInterface; +use Symfony\Component\DependencyInjection\ParameterBag\ContainerBag; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\HttpKernel\KernelInterface; + +/** + * Generate config json schema. + */ +final readonly class ConfigSchemaCacheWarmer implements CacheWarmerInterface +{ + public function __construct( + private KernelInterface $kernel, + private ?LoggerInterface $logger = null, + ) { + } + + public function warmUp(string $cacheDir, ?string $buildDir = null): array + { + if (!$buildDir || !$this->kernel->isDebug() || !class_exists(JsonSchemaGenerator::class)) { + return []; + } + + $generator = new JsonSchemaGenerator(\dirname($this->kernel->getBuildDir()).'/config.schema.json'); + + if ($this->kernel instanceof Kernel) { + /** @var ContainerBuilder $container */ + $container = \Closure::bind(function (Kernel $kernel) { + $containerBuilder = $kernel->getContainerBuilder(); + $kernel->prepareContainer($containerBuilder); + + return $containerBuilder; + }, null, $this->kernel)($this->kernel); + + $extensions = $container->getExtensions(); + } else { + $extensions = []; + foreach ($this->kernel->getBundles() as $bundle) { + $extension = $bundle->getContainerExtension(); + if (null !== $extension) { + $extensions[] = $extension; + } + } + } + + $tree = new ArrayNode($this->kernel->getEnvironment()); + foreach ($extensions as $extension) { + try { + $configuration = null; + if ($extension instanceof ConfigurationInterface) { + $configuration = $extension; + } elseif ($extension instanceof ConfigurationExtensionInterface) { + $container = $this->kernel->getContainer(); + $configuration = $extension->getConfiguration([], new ContainerBuilder($container instanceof Container ? new ContainerBag($container) : new ParameterBag())); + } + + if (!$extensionConfigNode = $configuration?->getConfigTreeBuilder()->buildTree()) { + continue; + } + + $tree->addChild($extensionConfigNode); + } catch (\Exception $e) { + $this->logger?->warning('Failed to generate JSON schema for extension {extensionClass}: '.$e->getMessage(), ['exception' => $e, 'extensionClass' => $extension::class]); + } + } + + try { + $generator->merge($tree, (object) [ + 'description' => 'Symfony configuration', + 'properties' => (object) [ + 'when@'.$tree->getName() => ['$ref' => '#/definitions/'.$tree->getName()], + ], + 'patternProperties' => (object) [ + 'when@[a-zA-Z0-9]+' => ['$ref' => '#/'], + ], + ]); + } catch (\Exception $e) { + $this->logger?->warning('Failed to generate JSON schema for the configuration: '.$e->getMessage(), ['exception' => $e]); + } + + // No need to preload anything + return []; + } + + public function isOptional(): bool + { + return true; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php index e5a86d8f411f5..25492aae28f14 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php @@ -14,6 +14,7 @@ use Psr\Clock\ClockInterface as PsrClockInterface; use Psr\EventDispatcher\EventDispatcherInterface as PsrEventDispatcherInterface; use Symfony\Bundle\FrameworkBundle\CacheWarmer\ConfigBuilderCacheWarmer; +use Symfony\Bundle\FrameworkBundle\CacheWarmer\ConfigSchemaCacheWarmer; use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache; use Symfony\Component\Clock\Clock; use Symfony\Component\Clock\ClockInterface; @@ -233,6 +234,10 @@ class_exists(WorkflowEvents::class) ? WorkflowEvents::ALIASES : [] ->args([service(KernelInterface::class), service('logger')->nullOnInvalid()]) ->tag('kernel.cache_warmer') + ->set('config_schema.warmer', ConfigSchemaCacheWarmer::class) + ->args([service(KernelInterface::class), service('logger')->nullOnInvalid()]) + ->tag('kernel.cache_warmer') + ->set('clock', Clock::class) ->alias(ClockInterface::class, 'clock') ->alias(PsrClockInterface::class, 'clock') diff --git a/src/Symfony/Component/Config/CHANGELOG.md b/src/Symfony/Component/Config/CHANGELOG.md index 577fbbca53645..b82aeabfdb028 100644 --- a/src/Symfony/Component/Config/CHANGELOG.md +++ b/src/Symfony/Component/Config/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add `ExprBuilder::ifFalse()` * Add support for info on `ArrayNodeDefinition::canBeEnabled()` and `ArrayNodeDefinition::canBeDisabled()` + * Add `JsonSchemaGenerator` 7.2 --- diff --git a/src/Symfony/Component/Config/Definition/BaseNode.php b/src/Symfony/Component/Config/Definition/BaseNode.php index 9cfd6923960ff..37c4c5be3244a 100644 --- a/src/Symfony/Component/Config/Definition/BaseNode.php +++ b/src/Symfony/Component/Config/Definition/BaseNode.php @@ -167,6 +167,14 @@ public function addEquivalentValue(mixed $originalValue, mixed $equivalentValue) $this->equivalentValues[] = [$originalValue, $equivalentValue]; } + /** + * @internal + */ + public function getEquivalentValues(): array + { + return $this->equivalentValues; + } + /** * Set this node as required. */ diff --git a/src/Symfony/Component/Config/Definition/BooleanNode.php b/src/Symfony/Component/Config/Definition/BooleanNode.php index b4ed0f0eb10f9..743f9ad1dc75a 100644 --- a/src/Symfony/Component/Config/Definition/BooleanNode.php +++ b/src/Symfony/Component/Config/Definition/BooleanNode.php @@ -29,6 +29,14 @@ public function __construct( parent::__construct($name, $parent, $pathSeparator); } + /** + * @internal + */ + public function isNullable(): bool + { + return $this->nullable; + } + protected function validateType(mixed $value): void { if (!\is_bool($value)) { diff --git a/src/Symfony/Component/Config/Definition/JsonSchemaGenerator.php b/src/Symfony/Component/Config/Definition/JsonSchemaGenerator.php new file mode 100644 index 0000000000000..26fa6112b8c4a --- /dev/null +++ b/src/Symfony/Component/Config/Definition/JsonSchemaGenerator.php @@ -0,0 +1,238 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Config\Definition; + +use Symfony\Component\Config\Definition\Builder\ExprBuilder; + +/** + * @experimental + */ +final readonly class JsonSchemaGenerator +{ + public function __construct(private string $outputPath) + { + } + + public function merge(NodeInterface $node, \stdClass $schema = new \stdClass()): void + { + if (file_exists($this->outputPath)) { + $previousSchema = json_decode(file_get_contents($this->outputPath), flags: \JSON_THROW_ON_ERROR); + foreach ($previousSchema as $key => $value) { + $schema->$key ??= $value; + } + } + + if (!\in_array($ref = '#/definitions/'.$node->getName(), array_column($schema->anyOf ??= [], '$ref'))) { + $schema->anyOf[] = (object) ['$ref' => $ref]; + } + + $this->build($node, $schema); + } + + public function build(NodeInterface $node, \stdClass $schema = new \stdClass()): void + { + $rootNodeName = $node->getName() ?? 'root'; + + $schema->{'$schema'} ??= 'http://json-schema.org/draft-06/schema#'; + if (!isset($schema->anyOf)) { + $schema->{'$ref'} ??= '#/definitions/'.$rootNodeName; + } + $schema->definitions ??= new \stdClass(); + $schema->definitions->$rootNodeName = $this->buildSingleNode($node, $schema->definitions, allowParam: false); + $schema->definitions->param = (object) [ + '$comment' => 'Container parameter', + 'type' => 'string', + 'pattern' => '^%[^%]+%$', + ]; + + file_put_contents($this->outputPath, json_encode($schema, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR)); + } + + private function buildSingleNode(NodeInterface $node, \stdClass $definitions, bool $allowParam = true): \stdClass + { + $schema = (object) match (\count($types = $this->createSubSchemas($node, $definitions, $allowParam))) { + 1 => $types[0], + default => ['anyOf' => $types], + }; + + if ($node->hasDefaultValue()) { + $schema->default = $node->getDefaultValue(); + } + + if ($node instanceof BaseNode) { + if ($info = $node->getInfo()) { + $schema->description = $info; + } + + if ($node->isDeprecated()) { + $schema->deprecated = true; + $deprecation = $node->getDeprecation($node->getName(), $node->getPath()); + $schema->deprecationMessage = ($deprecation['package'] || $deprecation['version'] ? "Since {$deprecation['package']} {$deprecation['version']}: " : '').$deprecation['message']; + } + } + + if (!isset($schema->{'$ref'})) { + $schema = $this->getReference($schema, $definitions); + } + + return $schema; + } + + private function createSubSchemas(NodeInterface $node, \stdClass $definitions, bool $allowParam = true): array + { + [$schema, $validateValue, $allowNull] = match (true) { + $node instanceof BooleanNode => [(object) ['type' => 'boolean'], is_bool(...), null], + $node instanceof IntegerNode => [$this->createNumericSchema($node), is_int(...), null], + $node instanceof NumericNode => [$this->createNumericSchema($node), is_float(...), false], + $node instanceof StringNode => [(object) ['type' => 'string'], is_string(...), ($node->isRequired() && $node->getAllowEmptyValue()) ?: null], + $node instanceof EnumNode => [$schema = $this->createEnumSchema($node), static fn ($v) => \in_array($v, $schema->enum), null], + $node instanceof PrototypedArrayNode => [$this->createArraySchema($node, $definitions), static fn () => false, null], + $node instanceof ArrayNode => [$this->createObjectSchema($node, $definitions), static fn () => false, null], + $node instanceof ScalarNode => [(object) ['type' => ['boolean', 'number', 'string']], is_scalar(...), null], + default => [new \stdClass(), static fn () => true, null], + }; + + $allowNull ??= (!$node->isRequired() && ($node->hasDefaultValue() || ($node instanceof VariableNode && $node->getAllowEmptyValue()))) + || ($node->hasDefaultValue() && null === $node->getDefaultValue()); + + $allowedExtraValues = []; + $subSchemas = null; + if ($node instanceof BaseNode && $normalizedTypes = $node->getNormalizedTypes()) { + $allowedExtraValues = array_column($node->getEquivalentValues(), 0); + $allowNull = $allowNull || \in_array(ExprBuilder::TYPE_NULL, $normalizedTypes); + if (\in_array(ExprBuilder::TYPE_ANY, $normalizedTypes)) { + // This will make IDEs not complain about configurations containing complex beforeNormalization logic + $subSchemas[] = new \stdClass(); + } + if (!\in_array('array', (array) ($schema->type ?? [])) && \in_array(ExprBuilder::TYPE_ARRAY, $normalizedTypes, true)) { + $subSchemas[] = (object) ['type' => $allowNull ? ['array', 'null'] : 'array']; + } + if (!\in_array('string', (array) ($schema->type ?? [])) && \in_array(ExprBuilder::TYPE_STRING, $normalizedTypes, true)) { + $subSchemas[] = (object) ['type' => $allowNull ? ['string', 'null'] : 'string']; + } + } + + if ($node->hasDefaultValue() && !\in_array($defaultValue = $node->getDefaultValue(), $allowedExtraValues, true)) { + $allowedExtraValues[] = $defaultValue; + } + + foreach ($allowedExtraValues as $i => $allowedExtraValue) { + if (null !== $allowedExtraValue && !\is_scalar($allowedExtraValue)) { + // IDEs don't seem to understand non-scalar values in "enum", so let's create separate sub-schema + unset($allowedExtraValues[$i]); + if (\is_array($allowedExtraValue) && array_is_list($allowedExtraValue)) { + $subSchemas[] = (object) ['const' => $allowedExtraValue]; + } + } + } + + if ($allowedValues = array_values(array_filter($allowedExtraValues, static fn (mixed $value) => !$validateValue($value)))) { + if (!isset($schema->enum)) { + // Append "boolean" to "type" instead of [true, false] to "enum" + if (false !== ($true = array_search(true, $allowedValues, true)) && false !== ($false = array_search(false, $allowedValues, true))) { + unset($allowedValues[$true], $allowedValues[$false]); + $this->addTypeToSchema($schema, 'boolean'); + } + + // Append "null" to "type" instead of null to "enum" + if (false !== ($null = array_search(null, $allowedValues, true))) { + unset($allowedValues[$null]); + $this->addTypeToSchema($schema, 'null'); + } + + if ($allowedValues) { + $subSchemas[] = (object) ['enum' => array_values($allowedValues)]; + } + } else { + $schema->enum = array_values(array_unique(array_merge($schema->enum, $allowedValues))); + } + } + + if ($schema) { + $subSchemas[] = $schema; + if ($allowParam && !\in_array('string', (array) ($schema->type ?? null))) { + $subSchemas[] = (object) ['$ref' => '#/definitions/param']; + } + } + + $subSchemas ??= [new \stdClass()]; + + return $subSchemas; + } + + private function createArraySchema(PrototypedArrayNode $node, \stdClass $definitions): \stdClass + { + $prototypeSchema = $this->buildSingleNode($node->getPrototype(), $definitions); + $prototypeRef = $this->getReference($prototypeSchema, $definitions); + + $schema = (object) [ + 'type' => ['array', 'object'], + 'items' => $prototypeRef, + 'additionalProperties' => $prototypeRef, + ]; + + if ($node->getMinNumberOfElements() > 0) { + $schema->minItems = $schema->minProperties = $node->getMinNumberOfElements(); + } + + return $schema; + } + + private function createObjectSchema(ArrayNode $node, \stdClass $definitions): \stdClass + { + $schema = (object) [ + 'type' => 'object', + 'additionalProperties' => $node->shouldIgnoreExtraKeys(), + ]; + + foreach ($node->getChildren() as $child) { + $schema->properties ??= new \stdClass(); + $schema->properties->{$child->getName()} = $this->buildSingleNode($child, $definitions); + } + + return $schema; + } + + private function createEnumSchema(EnumNode $node): \stdClass + { + return (object) ['enum' => array_map(static fn ($v) => $v instanceof \UnitEnum ? \sprintf('!php/enum %s::%s', $v::class, $v->name) : $v, $node->getValues())]; + } + + private function createNumericSchema(NumericNode $node): \stdClass + { + $schema = (object) ['type' => $node instanceof IntegerNode ? 'integer' : 'number']; + + if (null !== ($min = $node->getMin())) { + $schema->minimum = $min; + } + + if (null !== ($max = $node->getMax())) { + $schema->maximum = $max; + } + + return $schema; + } + + private function addTypeToSchema(\stdClass $schema, string $type): void + { + $schema->type = (array) ($schema->type ?? []); + $schema->type[] = $type; + } + + private function getReference(\stdClass $subSchema, \stdClass $definitions): \stdClass + { + $id = hash('xxh3', json_encode($subSchema)); + $definitions->$id ??= $subSchema; + + return (object) ['$ref' => '#/definitions/'.$id]; + } +} diff --git a/src/Symfony/Component/Config/Definition/NumericNode.php b/src/Symfony/Component/Config/Definition/NumericNode.php index a97850c9de746..f107dc5312b95 100644 --- a/src/Symfony/Component/Config/Definition/NumericNode.php +++ b/src/Symfony/Component/Config/Definition/NumericNode.php @@ -30,6 +30,22 @@ public function __construct( parent::__construct($name, $parent, $pathSeparator); } + /** + * @internal + */ + public function getMin(): int|float|null + { + return $this->min; + } + + /** + * @internal + */ + public function getMax(): int|float|null + { + return $this->max; + } + protected function finalizeValue(mixed $value): mixed { $value = parent::finalizeValue($value); diff --git a/src/Symfony/Component/Config/Definition/PrototypedArrayNode.php b/src/Symfony/Component/Config/Definition/PrototypedArrayNode.php index a901dab78d796..789d7719f7e2f 100644 --- a/src/Symfony/Component/Config/Definition/PrototypedArrayNode.php +++ b/src/Symfony/Component/Config/Definition/PrototypedArrayNode.php @@ -43,6 +43,14 @@ public function setMinNumberOfElements(int $number): void $this->minNumberOfElements = $number; } + /** + * @internal + */ + public function getMinNumberOfElements(): int + { + return $this->minNumberOfElements; + } + /** * Sets the attribute which value is to be used as key. * diff --git a/src/Symfony/Component/Config/Definition/VariableNode.php b/src/Symfony/Component/Config/Definition/VariableNode.php index ed1b903a19761..66c6d8185e688 100644 --- a/src/Symfony/Component/Config/Definition/VariableNode.php +++ b/src/Symfony/Component/Config/Definition/VariableNode.php @@ -55,6 +55,14 @@ public function setAllowEmptyValue(bool $boolean): void $this->allowEmptyValue = $boolean; } + /** + * @internal + */ + public function getAllowEmptyValue(): bool + { + return $this->allowEmptyValue; + } + public function setName(string $name): void { $this->name = $name; diff --git a/src/Symfony/Component/Config/Tests/Definition/Dumper/YamlReferenceDumperTest.php b/src/Symfony/Component/Config/Tests/Definition/Dumper/YamlReferenceDumperTest.php index cb33603f6cbb0..6ccd19bbc6dfc 100644 --- a/src/Symfony/Component/Config/Tests/Definition/Dumper/YamlReferenceDumperTest.php +++ b/src/Symfony/Component/Config/Tests/Definition/Dumper/YamlReferenceDumperTest.php @@ -23,7 +23,7 @@ public function testDumper() $dumper = new YamlReferenceDumper(); - $this->assertEquals($this->getConfigurationAsString(), $dumper->dump($configuration)); + $this->assertEquals($this->getConfigurationAsString(), "# \$schema: ExampleConfiguration.schema.json\n".$dumper->dump($configuration)); } public static function provideDumpAtPath(): array @@ -83,72 +83,6 @@ public function testDumpAtPath(string $path, string $expected) private function getConfigurationAsString(): string { - return <<<'EOL' -acme_root: - boolean: true - scalar_empty: ~ - scalar_null: null - scalar_true: true - scalar_false: false - scalar_default: default - scalar_array_empty: [] - scalar_array_defaults: - - # Defaults: - - elem1 - - elem2 - scalar_required: ~ # Required - scalar_deprecated: ~ # Deprecated (Since vendor/package 1.1: The child node "scalar_deprecated" at path "acme_root" is deprecated.) - scalar_deprecated_with_message: ~ # Deprecated (Since vendor/package 1.1: Deprecation custom message for "scalar_deprecated_with_message" at "acme_root") - node_with_a_looong_name: ~ - enum_with_default: this # One of "this"; "that" - enum: ~ # One of "this"; "that"; Symfony\Component\Config\Tests\Fixtures\TestEnum::Ccc - - # some info - array: - child1: ~ - child2: ~ - - # this is a long - # multi-line info text - # which should be indented - child3: ~ # Example: 'example setting' - scalar_prototyped: [] - variable: ~ - - # Examples: - # - foo - # - bar - parameters: - - # Prototype: Parameter name - name: ~ - connections: - - # Prototype - - - user: ~ - pass: ~ - cms_pages: - - # Prototype - page: - - # Prototype - locale: - title: ~ # Required - path: ~ # Required - pipou: - - # Prototype - name: [] - array_with_array_example_and_no_default_value: [] - - # Examples: - # - foo - # - bar - custom_node: true - -EOL; + return file_get_contents(__DIR__.'/../../Fixtures/Configuration/ExampleConfiguration.yaml'); } } diff --git a/src/Symfony/Component/Config/Tests/Definition/JsonSchemaGeneratorTest.php b/src/Symfony/Component/Config/Tests/Definition/JsonSchemaGeneratorTest.php new file mode 100644 index 0000000000000..6bd84398f34a5 --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Definition/JsonSchemaGeneratorTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Config\Tests\Definition; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\Definition\ArrayNode; +use Symfony\Component\Config\Definition\JsonSchemaGenerator; +use Symfony\Component\Config\Tests\Fixtures\Configuration\ExampleConfiguration; + +class JsonSchemaGeneratorTest extends TestCase +{ + private string $schemaFile; + + public function testExampleConfiguration() + { + $this->schemaFile = tempnam(sys_get_temp_dir(), 'json-schema-generator'); + $configuration = new ExampleConfiguration(); + + $root = new ArrayNode(null); + $root->addChild($configuration->getConfigTreeBuilder()->buildTree()); + + $generator = new JsonSchemaGenerator($this->schemaFile); + $generator->build($root); + + self::assertJsonFileEqualsJsonFile(__DIR__.'/../Fixtures/Configuration/ExampleConfiguration.schema.json', $this->schemaFile); + } + + /** + * @after + */ + public function removeTempFiles() + { + unlink($this->schemaFile); + } +} diff --git a/src/Symfony/Component/Config/Tests/Fixtures/Configuration/ExampleConfiguration.schema.json b/src/Symfony/Component/Config/Tests/Fixtures/Configuration/ExampleConfiguration.schema.json new file mode 100644 index 0000000000000..90f47fada57ce --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Fixtures/Configuration/ExampleConfiguration.schema.json @@ -0,0 +1,478 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "definitions": { + "param": { + "$comment": "Container parameter", + "type": "string", + "pattern": "^%[^%]+%$" + }, + "root": { + "type": "object", + "additionalProperties": false, + "properties": { + "acme_root": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "boolean": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/param" + } + ], + "default": true + }, + "scalar_empty": { + "type": [ + "boolean", + "number", + "string" + ] + }, + "scalar_null": { + "type": [ + "boolean", + "number", + "string", + "null" + ], + "default": null + }, + "scalar_true": { + "type": [ + "boolean", + "number", + "string" + ], + "default": true + }, + "scalar_false": { + "type": [ + "boolean", + "number", + "string" + ], + "default": false + }, + "scalar_default": { + "type": [ + "boolean", + "number", + "string" + ], + "default": "default" + }, + "scalar_array_empty": { + "anyOf": [ + { + "const": [] + }, + { + "type": [ + "boolean", + "number", + "string" + ] + } + ], + "default": [] + }, + "scalar_array_defaults": { + "anyOf": [ + { + "const": [ + "elem1", + "elem2" + ] + }, + { + "type": [ + "boolean", + "number", + "string" + ] + } + ], + "default": [ + "elem1", + "elem2" + ] + }, + "scalar_required": { + "type": [ + "boolean", + "number", + "string" + ] + }, + "scalar_deprecated": { + "type": [ + "boolean", + "number", + "string" + ], + "deprecated": true, + "deprecationMessage": "Since vendor/package 1.1: The child node \"scalar_deprecated\" at path \"acme_root.scalar_deprecated\" is deprecated." + }, + "scalar_deprecated_with_message": { + "type": [ + "boolean", + "number", + "string" + ], + "deprecated": true, + "deprecationMessage": "Since vendor/package 1.1: Deprecation custom message for \"scalar_deprecated_with_message\" at \"acme_root.scalar_deprecated_with_message\"" + }, + "node_with_a_looong_name": { + "type": [ + "boolean", + "number", + "string" + ] + }, + "enum_with_default": { + "anyOf": [ + { + "enum": [ + "this", + "that" + ] + }, + { + "$ref": "#/definitions/param" + } + ], + "default": "this" + }, + "enum": { + "anyOf": [ + { + "enum": [ + "this", + "that", + "!php/enum Symfony\\Component\\Config\\Tests\\Fixtures\\TestEnum::Ccc" + ] + }, + { + "$ref": "#/definitions/param" + } + ] + }, + "array": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "child1": { + "type": [ + "boolean", + "number", + "string" + ] + }, + "child2": { + "type": [ + "boolean", + "number", + "string" + ] + }, + "child3": { + "type": [ + "boolean", + "number", + "string" + ], + "description": "this is a long\nmulti-line info text\nwhich should be indented" + } + } + }, + { + "$ref": "#/definitions/param" + } + ], + "description": "some info" + }, + "scalar_prototyped": { + "anyOf": [ + { + "const": [] + }, + { + "type": [ + "array", + "object" + ], + "items": { + "$ref": "#/definitions/10b40a06ad0d1ec2" + }, + "additionalProperties": { + "$ref": "#/definitions/10b40a06ad0d1ec2" + } + }, + { + "$ref": "#/definitions/param" + } + ], + "default": [] + }, + "variable": {}, + "parameters": { + "anyOf": [ + { + "const": [] + }, + { + "type": [ + "array", + "object" + ], + "items": { + "$ref": "#/definitions/ebe6fbbc9144c534" + }, + "additionalProperties": { + "$ref": "#/definitions/ebe6fbbc9144c534" + } + }, + { + "$ref": "#/definitions/param" + } + ], + "default": [] + }, + "connections": { + "anyOf": [ + { + "const": [] + }, + { + "type": [ + "array", + "object" + ], + "items": { + "$ref": "#/definitions/58427bd26eb98f57" + }, + "additionalProperties": { + "$ref": "#/definitions/58427bd26eb98f57" + } + }, + { + "$ref": "#/definitions/param" + } + ], + "default": [] + }, + "cms_pages": { + "anyOf": [ + { + "const": [] + }, + { + "type": [ + "array", + "object" + ], + "items": { + "$ref": "#/definitions/95e9aba2df3b6782" + }, + "additionalProperties": { + "$ref": "#/definitions/95e9aba2df3b6782" + } + }, + { + "$ref": "#/definitions/param" + } + ], + "default": [] + }, + "pipou": { + "anyOf": [ + { + "const": [] + }, + { + "type": [ + "array", + "object" + ], + "items": { + "$ref": "#/definitions/c5bf1577d1316ba4" + }, + "additionalProperties": { + "$ref": "#/definitions/c5bf1577d1316ba4" + } + }, + { + "$ref": "#/definitions/param" + } + ], + "default": [] + }, + "array_with_array_example_and_no_default_value": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false + }, + { + "$ref": "#/definitions/param" + } + ] + }, + "custom_node": { + "default": true + } + } + }, + { + "$ref": "#/definitions/param" + } + ] + } + } + }, + "10b40a06ad0d1ec2": { + "type": [ + "boolean", + "number", + "string" + ] + }, + "ebe6fbbc9144c534": { + "type": [ + "boolean", + "number", + "string" + ], + "description": "Parameter name" + }, + "58427bd26eb98f57": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "user": { + "type": [ + "boolean", + "number", + "string" + ] + }, + "pass": { + "type": [ + "boolean", + "number", + "string" + ] + } + } + }, + { + "$ref": "#/definitions/param" + } + ] + }, + "879415d4739fa86f": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": [ + "boolean", + "number", + "string" + ] + }, + "path": { + "type": [ + "boolean", + "number", + "string" + ] + } + } + }, + { + "$ref": "#/definitions/param" + } + ] + }, + "95e9aba2df3b6782": { + "anyOf": [ + { + "const": [] + }, + { + "type": [ + "array", + "object" + ], + "items": { + "$ref": "#/definitions/879415d4739fa86f" + }, + "additionalProperties": { + "$ref": "#/definitions/879415d4739fa86f" + } + }, + { + "$ref": "#/definitions/param" + } + ], + "default": [] + }, + "1b75c3955224a671": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "didou": { + "type": [ + "boolean", + "number", + "string" + ] + } + } + }, + { + "$ref": "#/definitions/param" + } + ] + }, + "c5bf1577d1316ba4": { + "anyOf": [ + { + "const": [] + }, + { + "type": [ + "array", + "object" + ], + "items": { + "$ref": "#/definitions/1b75c3955224a671" + }, + "additionalProperties": { + "$ref": "#/definitions/1b75c3955224a671" + } + }, + { + "$ref": "#/definitions/param" + } + ], + "default": [] + } + }, + "$ref": "#/definitions/root" +} diff --git a/src/Symfony/Component/Config/Tests/Fixtures/Configuration/ExampleConfiguration.yaml b/src/Symfony/Component/Config/Tests/Fixtures/Configuration/ExampleConfiguration.yaml new file mode 100644 index 0000000000000..cad49c749954d --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Fixtures/Configuration/ExampleConfiguration.yaml @@ -0,0 +1,65 @@ +# $schema: ExampleConfiguration.schema.json +acme_root: + boolean: true + scalar_empty: ~ + scalar_null: null + scalar_true: true + scalar_false: false + scalar_default: default + scalar_array_empty: [] + scalar_array_defaults: + + # Defaults: + - elem1 + - elem2 + scalar_required: ~ # Required + scalar_deprecated: ~ # Deprecated (Since vendor/package 1.1: The child node "scalar_deprecated" at path "acme_root" is deprecated.) + scalar_deprecated_with_message: ~ # Deprecated (Since vendor/package 1.1: Deprecation custom message for "scalar_deprecated_with_message" at "acme_root") + node_with_a_looong_name: ~ + enum_with_default: this # One of "this"; "that" + enum: ~ # One of "this"; "that"; Symfony\Component\Config\Tests\Fixtures\TestEnum::Ccc + + # some info + array: + child1: ~ + child2: ~ + + # this is a long + # multi-line info text + # which should be indented + child3: ~ # Example: 'example setting' + scalar_prototyped: [] + variable: ~ + + # Examples: + # - foo + # - bar + parameters: + + # Prototype: Parameter name + name: ~ + connections: + + # Prototype + - + user: ~ + pass: ~ + cms_pages: + + # Prototype + page: + + # Prototype + locale: + title: ~ # Required + path: ~ # Required + pipou: + + # Prototype + name: [] + array_with_array_example_and_no_default_value: [] + + # Examples: + # - foo + # - bar + custom_node: true