Skip to content

Commit 80efba3

Browse files
committed
Generate JSON schema for config
1 parent d4566b2 commit 80efba3

File tree

13 files changed

+1349
-68
lines changed

13 files changed

+1349
-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: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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, ['description' => 'Symfony configuration']);
89+
} catch (\Exception $e) {
90+
$this->logger?->warning('Failed to generate JSON schema for the configuration: '.$e->getMessage(), ['exception' => $e]);
91+
}
92+
93+
// No need to preload anything
94+
return [];
95+
}
96+
97+
public function isOptional(): bool
98+
{
99+
return false;
100+
}
101+
}

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/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: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
namespace Symfony\Component\Config\Definition;
4+
5+
use Symfony\Component\Config\Definition\Builder\ExprBuilder;
6+
7+
final readonly class JsonSchemaGenerator
8+
{
9+
public function __construct(private string $outputPath)
10+
{
11+
}
12+
13+
public function build(NodeInterface $node, array $schema = []): void
14+
{
15+
$schema = array_replace_recursive([
16+
'$schema' => 'http://json-schema.org/draft-06/schema#',
17+
'definitions' => [
18+
'param' => [
19+
'$comment' => 'Container parameter',
20+
'type' => 'string',
21+
'pattern' => '^%[^%]+%$',
22+
],
23+
],
24+
], $this->buildSingleNode($node, allowParam: false), $schema);
25+
26+
file_put_contents($this->outputPath, json_encode($schema, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR));
27+
}
28+
29+
private function buildArrayNode(ArrayNode $node): array
30+
{
31+
$schema = [
32+
'type' => 'object',
33+
'additionalProperties' => $node->shouldIgnoreExtraKeys(),
34+
];
35+
36+
foreach ($node->getChildren() as $child) {
37+
$schema['properties'][$child->getName()] = $this->buildSingleNode($child);
38+
39+
if ($child->isRequired()) {
40+
$schema['required'][] = $child->getName();
41+
}
42+
}
43+
44+
return $schema;
45+
}
46+
47+
private function buildSingleNode(NodeInterface $node, bool $allowParam = true): array|\ArrayObject
48+
{
49+
$schema = match (\count($types = $this->createSubSchemas($node, $allowParam))) {
50+
1 => $types[0],
51+
default => ['anyOf' => $types],
52+
};
53+
54+
if ($node->hasDefaultValue()) {
55+
$schema['default'] = $node->getDefaultValue();
56+
}
57+
58+
if ($node instanceof BaseNode) {
59+
if ($info = $node->getInfo()) {
60+
$schema['description'] = $info;
61+
}
62+
63+
if ($node->isDeprecated()) {
64+
$schema['deprecated'] = true;
65+
}
66+
}
67+
68+
return $schema;
69+
}
70+
71+
private function createSubSchemas(NodeInterface $node, bool $allowParam = true): array
72+
{
73+
$paramTypes = [];
74+
75+
$getType = fn ($value) => match (get_debug_type($value)) {
76+
'string' => 'string',
77+
'int' => 'integer',
78+
default => null,
79+
};
80+
81+
$removeNulls = fn (array $array) => array_filter($array, fn ($value) => null !== $value);
82+
83+
if ($node instanceof BaseNode && !$node instanceof StringNode && \in_array(ExprBuilder::TYPE_STRING, $node->getNormalizedTypes(), true)) {
84+
$paramTypes[] = ['type' => 'string'];
85+
}
86+
87+
$schema = match (true) {
88+
$node instanceof BooleanNode => [['type' => 'boolean']],
89+
$node instanceof IntegerNode => [$removeNulls(['type' => 'integer', 'minimum' => $node->getMin(), 'maximum' => $node->getMax()])],
90+
$node instanceof NumericNode => [$removeNulls(['type' => 'number', 'minimum' => $node->getMin(), 'maximum' => $node->getMax()])],
91+
$node instanceof StringNode => [['type' => 'string']],
92+
$node instanceof EnumNode => [$removeNulls(
93+
($nonEnumValues = array_values(array_filter($node->getValues(), fn ($v) => !$v instanceof \UnitEnum))) ? [
94+
'type' => array_values(
95+
array_unique(array_filter(array_map($getType, $nonEnumValues)))
96+
),
97+
'enum' => $nonEnumValues,
98+
] : []
99+
)],
100+
$node instanceof PrototypedArrayNode => $this->buildPrototypedArray($node),
101+
$node instanceof ArrayNode => [$this->buildArrayNode($node)],
102+
$node instanceof ScalarNode => [['type' => ['string', 'number', 'boolean']]],
103+
default => null,
104+
};
105+
106+
if ($schema) {
107+
array_push($paramTypes, ...$schema);
108+
if ($allowParam) {
109+
$paramTypes[] = ['$ref' => '#/definitions/param'];
110+
}
111+
}
112+
113+
if (
114+
!($node instanceof NumericNode) && (
115+
($node instanceof BooleanNode && $node->isNullable())
116+
|| ($node->isRequired() && $node instanceof StringNode && $node->getAllowEmptyValue())
117+
|| (!$node->isRequired() && ($node->hasDefaultValue() || ($node instanceof VariableNode && $node->getAllowEmptyValue())))
118+
|| ($node->hasDefaultValue() && null === $node->getDefaultValue())
119+
)
120+
) {
121+
foreach ($paramTypes as &$subSchema) {
122+
if (!isset($subSchema['type'])) {
123+
continue;
124+
}
125+
126+
$subSchema['type'] = (array) $subSchema['type'];
127+
$subSchema['type'][] = 'null';
128+
}
129+
}
130+
131+
return $paramTypes ?: [new \ArrayObject()];
132+
}
133+
134+
private function buildPrototypedArray(PrototypedArrayNode $node): array
135+
{
136+
$items = $this->buildSingleNode($node->getPrototype());
137+
138+
$schema = [
139+
['type' => 'array', 'items' => $items],
140+
['type' => 'object', 'additionalProperties' => $items],
141+
];
142+
143+
if ($node->getMinNumberOfElements() > 0) {
144+
$schema[0]['minItems'] = $schema[1]['minItems'] = $node->getMinNumberOfElements();
145+
}
146+
147+
return $schema;
148+
}
149+
}

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
*

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ public function setAllowEmptyValue(bool $boolean): void
5555
$this->allowEmptyValue = $boolean;
5656
}
5757

58+
/**
59+
* @internal
60+
*/
61+
public function getAllowEmptyValue(): bool
62+
{
63+
return $this->allowEmptyValue;
64+
}
65+
5866
public function setName(string $name): void
5967
{
6068
$this->name = $name;

0 commit comments

Comments
 (0)