Skip to content

Commit 2d3536a

Browse files
committed
Generate JSON schema for config
1 parent d4566b2 commit 2d3536a

File tree

14 files changed

+1587
-68
lines changed

14 files changed

+1587
-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
@@ -5,6 +5,7 @@ CHANGELOG
55
---
66

77
* Add `ExprBuilder::ifFalse()`
8+
* Add `JsonSchemaGenerator`
89

910
7.2
1011
---

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: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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+
$schema = array_replace_recursive([
28+
'$schema' => 'http://json-schema.org/draft-06/schema#',
29+
'definitions' => [
30+
'param' => [
31+
'$comment' => 'Container parameter',
32+
'type' => 'string',
33+
'pattern' => '^%[^%]+%$',
34+
],
35+
'root' => $this->buildSingleNode($node, allowParam: false),
36+
],
37+
'$ref' => '#/definitions/root',
38+
], $schema);
39+
40+
file_put_contents($this->outputPath, json_encode($schema, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR));
41+
}
42+
43+
private function buildArrayNode(ArrayNode $node): array
44+
{
45+
$schema = [
46+
'type' => 'object',
47+
'additionalProperties' => $node->shouldIgnoreExtraKeys(),
48+
];
49+
50+
foreach ($node->getChildren() as $child) {
51+
$schema['properties'][$child->getName()] = $this->buildSingleNode($child);
52+
}
53+
54+
return $schema;
55+
}
56+
57+
private function buildSingleNode(NodeInterface $node, bool $allowParam = true): array|\ArrayObject
58+
{
59+
$schema = match (\count($types = $this->createSubSchemas($node, $allowParam))) {
60+
1 => $types[0],
61+
default => ['anyOf' => $types],
62+
};
63+
64+
if ($node->hasDefaultValue()) {
65+
$schema['default'] = $node->getDefaultValue();
66+
}
67+
68+
if ($node instanceof BaseNode) {
69+
if ($info = $node->getInfo()) {
70+
$schema['description'] = $info;
71+
}
72+
73+
if ($node->isDeprecated()) {
74+
$schema['deprecated'] = true;
75+
}
76+
}
77+
78+
return $schema;
79+
}
80+
81+
private function createSubSchemas(NodeInterface $node, bool $allowParam = true): array
82+
{
83+
$paramTypes = [];
84+
85+
$getType = fn ($value) => match (get_debug_type($value)) {
86+
'string' => 'string',
87+
'int' => 'integer',
88+
default => null,
89+
};
90+
91+
$removeNulls = fn (array $array) => array_filter($array, fn ($value) => null !== $value);
92+
93+
if ($node instanceof BaseNode && !$node instanceof StringNode && \in_array(ExprBuilder::TYPE_STRING, $node->getNormalizedTypes(), true)) {
94+
$paramTypes[] = ['type' => 'string'];
95+
}
96+
97+
$pseudoType = match (true) {
98+
$node instanceof BooleanNode => 'bool',
99+
$node instanceof IntegerNode => 'int',
100+
$node instanceof NumericNode => 'float',
101+
$node instanceof StringNode => 'string',
102+
$node instanceof EnumNode => 'enum',
103+
$node instanceof PrototypedArrayNode => 'array_prototype',
104+
$node instanceof ArrayNode => 'array',
105+
$node instanceof ScalarNode => 'scalar',
106+
default => null,
107+
};
108+
109+
$schema = match ($pseudoType) {
110+
'bool' => [['type' => 'boolean']],
111+
'int' => [$removeNulls(['type' => 'integer', 'minimum' => $node->getMin(), 'maximum' => $node->getMax()])],
112+
'float' => [$removeNulls(['type' => 'number', 'minimum' => $node->getMin(), 'maximum' => $node->getMax()])],
113+
'string' => [['type' => 'string']],
114+
'enum' => [$removeNulls(
115+
($nonEnumValues = array_values(array_filter($node->getValues(), fn ($v) => !$v instanceof \UnitEnum))) ? [
116+
'type' => array_values(
117+
array_unique(array_filter(array_map($getType, $nonEnumValues)))
118+
),
119+
'enum' => $nonEnumValues,
120+
] : [],
121+
)],
122+
'array_prototype' => $this->buildPrototypedArray($node),
123+
'array' => [$this->buildArrayNode($node)],
124+
'scalar' => [['type' => ['string', 'number', 'boolean']]],
125+
default => null,
126+
};
127+
128+
$allowNull = !($node instanceof NumericNode) && (
129+
($node instanceof BooleanNode && $node->isNullable())
130+
|| ($node->isRequired() && $node instanceof StringNode && $node->getAllowEmptyValue())
131+
|| (!$node->isRequired() && ($node->hasDefaultValue() || ($node instanceof VariableNode && $node->getAllowEmptyValue())))
132+
|| ($node->hasDefaultValue() && null === $node->getDefaultValue())
133+
);
134+
135+
if ($node instanceof BaseNode) {
136+
$map = fn (array $mapping) => match (true) {
137+
null === $mapping[0] => !$allowNull,
138+
get_debug_type($mapping[0]) === $pseudoType => false,
139+
'scalar' === $pseudoType => !\is_scalar($mapping[0]),
140+
default => true,
141+
};
142+
if ($equivalentValues = array_column(array_filter($node->getEquivalentValues(), $map), 0)) {
143+
$paramTypes[] = ['enum' => $equivalentValues];
144+
}
145+
}
146+
147+
if ($schema) {
148+
array_push($paramTypes, ...$schema);
149+
if ($allowParam) {
150+
$paramTypes[] = ['$ref' => '#/definitions/param'];
151+
}
152+
}
153+
154+
if ($allowNull) {
155+
foreach ($paramTypes as &$subSchema) {
156+
if (!isset($subSchema['type'])) {
157+
continue;
158+
}
159+
160+
$subSchema['type'] = (array) $subSchema['type'];
161+
$subSchema['type'][] = 'null';
162+
}
163+
}
164+
165+
return $paramTypes ?: [new \ArrayObject()];
166+
}
167+
168+
private function buildPrototypedArray(PrototypedArrayNode $node): array
169+
{
170+
$items = $this->buildSingleNode($node->getPrototype());
171+
172+
$schema = [
173+
['type' => 'array', 'items' => $items],
174+
['type' => 'object', 'additionalProperties' => $items],
175+
];
176+
177+
if ($node->getMinNumberOfElements() > 0) {
178+
$schema[0]['minItems'] = $schema[1]['minItems'] = $node->getMinNumberOfElements();
179+
}
180+
181+
return $schema;
182+
}
183+
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,22 @@ public function __construct(
3030
parent::__construct($name, $parent, $pathSeparator);
3131
}
3232

33+
/**
34+
* @internal
35+
*/
36+
public function getMin(): int|float|null
37+
{
38+
return $this->min;
39+
}
40+
41+
/**
42+
* @internal
43+
*/
44+
public function getMax(): int|float|null
45+
{
46+
return $this->max;
47+
}
48+
3349
protected function finalizeValue(mixed $value): mixed
3450
{
3551
$value = parent::finalizeValue($value);

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ public function setMinNumberOfElements(int $number): void
4343
$this->minNumberOfElements = $number;
4444
}
4545

46+
/**
47+
* @internal
48+
*/
49+
public function getMinNumberOfElements(): int
50+
{
51+
return $this->minNumberOfElements;
52+
}
53+
4654
/**
4755
* Sets the attribute which value is to be used as key.
4856
*

0 commit comments

Comments
 (0)