Skip to content

Commit de09b68

Browse files
committed
Generate JSON schema for config
1 parent eff9b52 commit de09b68

File tree

14 files changed

+969
-68
lines changed

14 files changed

+969
-68
lines changed

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ CHANGELOG
99
* Add JsonEncoder services and configuration
1010
* Add new `framework.property_info.with_constructor_extractor` option to allow enabling or disabling the constructor extractor integration
1111
* Deprecate the `--show-arguments` option of the `container:debug` command, as arguments are now always shown
12+
* Generate JSON schema for YAML configuration
1213

1314
7.2
1415
---
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\FrameworkBundle\CacheWarmer;
13+
14+
use Psr\Log\LoggerInterface;
15+
use Symfony\Component\Config\Definition\ArrayNode;
16+
use Symfony\Component\Config\Definition\ConfigurationInterface;
17+
use Symfony\Component\Config\Definition\JsonSchemaGenerator;
18+
use Symfony\Component\DependencyInjection\Container;
19+
use Symfony\Component\DependencyInjection\ContainerBuilder;
20+
use Symfony\Component\DependencyInjection\Extension\ConfigurationExtensionInterface;
21+
use Symfony\Component\DependencyInjection\ParameterBag\ContainerBag;
22+
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
23+
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
24+
use Symfony\Component\HttpKernel\Kernel;
25+
use Symfony\Component\HttpKernel\KernelInterface;
26+
27+
/**
28+
* Generate config json schema.
29+
*/
30+
final readonly class ConfigSchemaCacheWarmer implements CacheWarmerInterface
31+
{
32+
public function __construct(
33+
private KernelInterface $kernel,
34+
private ?LoggerInterface $logger = null,
35+
) {
36+
}
37+
38+
public function warmUp(string $cacheDir, ?string $buildDir = null): array
39+
{
40+
if (!$buildDir || !$this->kernel->isDebug() || !class_exists(JsonSchemaGenerator::class)) {
41+
return [];
42+
}
43+
44+
$generator = new JsonSchemaGenerator($buildDir.'/config.schema.json');
45+
46+
if ($this->kernel instanceof Kernel) {
47+
/** @var ContainerBuilder $container */
48+
$container = \Closure::bind(function (Kernel $kernel) {
49+
$containerBuilder = $kernel->getContainerBuilder();
50+
$kernel->prepareContainer($containerBuilder);
51+
52+
return $containerBuilder;
53+
}, null, $this->kernel)($this->kernel);
54+
55+
$extensions = $container->getExtensions();
56+
} else {
57+
$extensions = [];
58+
foreach ($this->kernel->getBundles() as $bundle) {
59+
$extension = $bundle->getContainerExtension();
60+
if (null !== $extension) {
61+
$extensions[] = $extension;
62+
}
63+
}
64+
}
65+
66+
$tree = new ArrayNode(null);
67+
foreach ($extensions as $extension) {
68+
try {
69+
$configuration = null;
70+
if ($extension instanceof ConfigurationInterface) {
71+
$configuration = $extension;
72+
} elseif ($extension instanceof ConfigurationExtensionInterface) {
73+
$container = $this->kernel->getContainer();
74+
$configuration = $extension->getConfiguration([], new ContainerBuilder($container instanceof Container ? new ContainerBag($container) : new ParameterBag()));
75+
}
76+
77+
if (!$extensionConfigNode = $configuration?->getConfigTreeBuilder()->buildTree()) {
78+
continue;
79+
}
80+
81+
$tree->addChild($extensionConfigNode);
82+
} catch (\Exception $e) {
83+
$this->logger?->warning('Failed to generate JSON schema for extension {extensionClass}: '.$e->getMessage(), ['exception' => $e, 'extensionClass' => $extension::class]);
84+
}
85+
}
86+
87+
try {
88+
$generator->build($tree, [
89+
'description' => 'Symfony configuration',
90+
'patternProperties' => [
91+
'when@[a-zA-Z0-9]+' => ['$ref' => '#/definitions/root'],
92+
],
93+
]);
94+
} catch (\Exception $e) {
95+
$this->logger?->warning('Failed to generate JSON schema for the configuration: '.$e->getMessage(), ['exception' => $e]);
96+
}
97+
98+
// No need to preload anything
99+
return [];
100+
}
101+
102+
public function isOptional(): bool
103+
{
104+
return false;
105+
}
106+
}

src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Psr\Clock\ClockInterface as PsrClockInterface;
1515
use Psr\EventDispatcher\EventDispatcherInterface as PsrEventDispatcherInterface;
1616
use Symfony\Bundle\FrameworkBundle\CacheWarmer\ConfigBuilderCacheWarmer;
17+
use Symfony\Bundle\FrameworkBundle\CacheWarmer\ConfigSchemaCacheWarmer;
1718
use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache;
1819
use Symfony\Component\Clock\Clock;
1920
use Symfony\Component\Clock\ClockInterface;
@@ -233,6 +234,10 @@ class_exists(WorkflowEvents::class) ? WorkflowEvents::ALIASES : []
233234
->args([service(KernelInterface::class), service('logger')->nullOnInvalid()])
234235
->tag('kernel.cache_warmer')
235236

237+
->set('config_schema.warmer', ConfigSchemaCacheWarmer::class)
238+
->args([service(KernelInterface::class), service('logger')->nullOnInvalid()])
239+
->tag('kernel.cache_warmer')
240+
236241
->set('clock', Clock::class)
237242
->alias(ClockInterface::class, 'clock')
238243
->alias(PsrClockInterface::class, 'clock')

src/Symfony/Component/Config/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ CHANGELOG
66

77
* Add `ExprBuilder::ifFalse()`
88
* Add support for info on `ArrayNodeDefinition::canBeEnabled()` and `ArrayNodeDefinition::canBeDisabled()`
9+
* Add `JsonSchemaGenerator`
910

1011
7.2
1112
---

src/Symfony/Component/Config/Definition/BaseNode.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ public function addEquivalentValue(mixed $originalValue, mixed $equivalentValue)
167167
$this->equivalentValues[] = [$originalValue, $equivalentValue];
168168
}
169169

170+
/**
171+
* @internal
172+
*/
173+
public function getEquivalentValues(): array
174+
{
175+
return $this->equivalentValues;
176+
}
177+
170178
/**
171179
* Set this node as required.
172180
*/

src/Symfony/Component/Config/Definition/BooleanNode.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ public function __construct(
2929
parent::__construct($name, $parent, $pathSeparator);
3030
}
3131

32+
/**
33+
* @internal
34+
*/
35+
public function isNullable(): bool
36+
{
37+
return $this->nullable;
38+
}
39+
3240
protected function validateType(mixed $value): void
3341
{
3442
if (!\is_bool($value)) {
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Config\Definition;
13+
14+
use Symfony\Component\Config\Definition\Builder\ExprBuilder;
15+
16+
/**
17+
* @experimental
18+
*/
19+
final readonly class JsonSchemaGenerator
20+
{
21+
public function __construct(private string $outputPath)
22+
{
23+
}
24+
25+
public function build(NodeInterface $node, array $schema = []): void
26+
{
27+
$definitions = [];
28+
$schema = array_replace_recursive([
29+
'$schema' => 'http://json-schema.org/draft-06/schema#',
30+
'definitions' => [
31+
'param' => [
32+
'$comment' => 'Container parameter',
33+
'type' => 'string',
34+
'pattern' => '^%[^%]+%$',
35+
],
36+
'root' => $this->buildSingleNode($node, $definitions, allowParam: false),
37+
],
38+
'$ref' => '#/definitions/root',
39+
], ['definitions' => $definitions], $schema);
40+
41+
file_put_contents($this->outputPath, json_encode($schema, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR));
42+
}
43+
44+
private function buildSingleNode(NodeInterface $node, array &$definitions, bool $allowParam = true): array|\ArrayObject
45+
{
46+
$schema = match (\count($types = $this->createSubSchemas($node, $definitions, $allowParam))) {
47+
1 => $types[0],
48+
default => ['anyOf' => $types],
49+
};
50+
51+
if ($node->hasDefaultValue()) {
52+
$schema['default'] = $node->getDefaultValue();
53+
}
54+
55+
if ($node instanceof BaseNode) {
56+
if ($info = $node->getInfo()) {
57+
$schema['description'] = $info;
58+
}
59+
60+
if ($node->isDeprecated()) {
61+
$schema['deprecated'] = true;
62+
$deprecation = $node->getDeprecation($node->getName(), $node->getPath());
63+
$schema['deprecationMessage'] = ($deprecation['package'] || $deprecation['version'] ? "Since {$deprecation['package']} {$deprecation['version']}: " : '').$deprecation['message'];
64+
}
65+
}
66+
67+
return $schema;
68+
}
69+
70+
private function createSubSchemas(NodeInterface $node, array &$definitions, bool $allowParam = true): array
71+
{
72+
[$schema, $validateValue, $allowNull] = match (true) {
73+
$node instanceof BooleanNode => [['type' => 'boolean'], is_bool(...), null],
74+
$node instanceof IntegerNode => [$this->createNumericSchema($node), is_int(...), null],
75+
$node instanceof NumericNode => [$this->createNumericSchema($node), is_float(...), false],
76+
$node instanceof StringNode => [['type' => 'string'], is_string(...), ($node->isRequired() && $node->getAllowEmptyValue()) ?: null],
77+
$node instanceof EnumNode => [$schema = $this->createEnumSchema($node), static fn ($v) => \in_array($v, $schema['enum']), null],
78+
$node instanceof PrototypedArrayNode => [$this->createArraySchema($node, $definitions), static fn () => false, null],
79+
$node instanceof ArrayNode => [$this->createObjectSchema($node, $definitions), static fn () => false, null],
80+
$node instanceof ScalarNode => [['type' => ['boolean', 'number', 'string']], is_scalar(...), null],
81+
default => [[], static fn () => true, null],
82+
};
83+
84+
$allowNull ??= (!$node->isRequired() && ($node->hasDefaultValue() || ($node instanceof VariableNode && $node->getAllowEmptyValue())))
85+
|| ($node->hasDefaultValue() && null === $node->getDefaultValue());
86+
87+
$allowedExtraValues = $subSchemas = [];
88+
if ($node instanceof BaseNode && $normalizedTypes = $node->getNormalizedTypes()) {
89+
$allowedExtraValues = array_column($node->getEquivalentValues(), 0);
90+
$allowNull = $allowNull || \in_array(ExprBuilder::TYPE_NULL, $normalizedTypes);
91+
if (\in_array(ExprBuilder::TYPE_ANY, $normalizedTypes)) {
92+
// This will make IDEs not complain about configurations containing complex beforeNormalization logic
93+
$subSchemas[] = new \ArrayObject();
94+
}
95+
if (!\in_array('array', (array) ($schema['type'] ?? [])) && \in_array(ExprBuilder::TYPE_ARRAY, $normalizedTypes, true)) {
96+
$subSchemas[] = ['type' => $allowNull ? ['array', 'null'] : 'array'];
97+
}
98+
if (!\in_array('string', (array) ($schema['type'] ?? [])) && \in_array(ExprBuilder::TYPE_STRING, $normalizedTypes, true)) {
99+
$subSchemas[] = ['type' => $allowNull ? ['string', 'null'] : 'string'];
100+
}
101+
}
102+
103+
if ($node->hasDefaultValue() && !\in_array($defaultValue = $node->getDefaultValue(), $allowedExtraValues, true)) {
104+
$allowedExtraValues[] = $defaultValue;
105+
}
106+
107+
foreach ($allowedExtraValues as $i => $allowedExtraValue) {
108+
if (null !== $allowedExtraValue && !\is_scalar($allowedExtraValue)) {
109+
// IDEs don't seem to understand non-scalar values in "enum", so let's create separate sub-schema
110+
unset($allowedExtraValues[$i]);
111+
if (\is_array($allowedExtraValue) && array_is_list($allowedExtraValue)) {
112+
$subSchemas[] = ['const' => $allowedExtraValue];
113+
}
114+
}
115+
}
116+
117+
if ($allowedValues = array_values(array_filter($allowedExtraValues, static fn (mixed $value) => !$validateValue($value)))) {
118+
if (!isset($schema['enum'])) {
119+
// Append "boolean" to "type" instead of [true, false] to "enum"
120+
if (false !== ($true = array_search(true, $allowedValues, true)) && false !== ($false = array_search(false, $allowedValues, true))) {
121+
unset($allowedValues[$true], $allowedValues[$false]);
122+
$this->addTypeToSchema($schema, 'boolean');
123+
}
124+
125+
// Append "null" to "type" instead of null to "enum"
126+
if (false !== ($null = array_search(null, $allowedValues, true))) {
127+
unset($allowedValues[$null]);
128+
$this->addTypeToSchema($schema, 'null');
129+
}
130+
131+
if ($allowedValues) {
132+
$subSchemas[] = ['enum' => array_values($allowedValues)];
133+
}
134+
} else {
135+
$schema['enum'] = array_values(array_unique(array_merge($schema['enum'], $allowedValues)));
136+
}
137+
}
138+
139+
if ($schema) {
140+
$subSchemas[] = $schema;
141+
if ($allowParam && !\in_array('string', (array) ($schema['type'] ?? null))) {
142+
$subSchemas[] = ['$ref' => '#/definitions/param'];
143+
}
144+
}
145+
146+
return $subSchemas ?: [new \ArrayObject()];
147+
}
148+
149+
private function createArraySchema(PrototypedArrayNode $node, array &$definitions): array
150+
{
151+
$prototypeSchema = $this->buildSingleNode($node->getPrototype(), $definitions);
152+
$prototypeRef = $this->getReference($prototypeSchema, $definitions);
153+
154+
$schema = [
155+
'type' => ['array', 'object'],
156+
'items' => $prototypeRef,
157+
'additionalProperties' => $prototypeRef,
158+
];
159+
160+
if ($node->getMinNumberOfElements() > 0) {
161+
$schema['minItems'] = $schema['minProperties'] = $node->getMinNumberOfElements();
162+
}
163+
164+
return $schema;
165+
}
166+
167+
private function createObjectSchema(ArrayNode $node, array &$definitions): array
168+
{
169+
$schema = [
170+
'type' => 'object',
171+
'additionalProperties' => $node->shouldIgnoreExtraKeys(),
172+
];
173+
174+
foreach ($node->getChildren() as $child) {
175+
$schema['properties'][$child->getName()] = $this->buildSingleNode($child, $definitions);
176+
}
177+
178+
return $schema;
179+
}
180+
181+
private function createEnumSchema(EnumNode $node): array
182+
{
183+
return ['enum' => array_map(static fn ($v) => $v instanceof \UnitEnum ? \sprintf('!php/enum %s::%s', $v::class, $v->name) : $v, $node->getValues())];
184+
}
185+
186+
public function createNumericSchema(NumericNode $node): array
187+
{
188+
$schema = ['type' => $node instanceof IntegerNode ? 'integer' : 'number'];
189+
190+
if (null !== ($min = $node->getMin())) {
191+
$schema['minimum'] = $min;
192+
}
193+
194+
if (null !== ($max = $node->getMax())) {
195+
$schema['maximum'] = $max;
196+
}
197+
198+
return $schema;
199+
}
200+
201+
private function addTypeToSchema(array &$schema, string $type): void
202+
{
203+
$schema['type'] = (array) ($schema['type'] ?? []);
204+
$schema['type'][] = $type;
205+
}
206+
207+
private function getReference(array|\ArrayObject $subSchema, array &$definitions): array|\ArrayObject
208+
{
209+
// If there's max 1 element, no point trying to shrink it
210+
if (\count($subSchema, \COUNT_RECURSIVE) <= 1) {
211+
return $subSchema;
212+
}
213+
214+
$id = hash('xxh3', json_encode($subSchema));
215+
$definitions[$id] ??= $subSchema;
216+
217+
return ['$ref' => '#/definitions/'.$id];
218+
}
219+
}

0 commit comments

Comments
 (0)