|
| 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 | +namespaceSymfony\Component\Config\Definition; |
| 13 | + |
| 14 | +useSymfony\Component\Config\Definition\Builder\ExprBuilder; |
| 15 | + |
| 16 | +/** |
| 17 | + * @experimental |
| 18 | + */ |
| 19 | +finalreadonlyclass JsonSchemaGenerator |
| 20 | +{ |
| 21 | +publicfunction__construct(privatestring$outputPath) |
| 22 | + { |
| 23 | + } |
| 24 | + |
| 25 | +publicfunctionbuild(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 | +privatefunctionbuildSingleNode(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 ($nodeinstanceof 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 | +privatefunctioncreateSubSchemas(NodeInterface$node,array &$definitions,bool$allowParam =true):array |
| 71 | + { |
| 72 | + [$schema,$validateValue,$allowNull] =match (true) { |
| 73 | +$nodeinstanceof BooleanNode => [['type' =>'boolean'],is_bool(...),null], |
| 74 | +$nodeinstanceof IntegerNode => [$this->createNumericSchema($node),is_int(...),null], |
| 75 | +$nodeinstanceof NumericNode => [$this->createNumericSchema($node),is_float(...),false], |
| 76 | +$nodeinstanceof StringNode => [['type' =>'string'],is_string(...), ($node->isRequired() &&$node->getAllowEmptyValue()) ?:null], |
| 77 | +$nodeinstanceof EnumNode => [$schema =$this->createEnumSchema($node),staticfn ($v) =>\in_array($v,$schema['enum']),null], |
| 78 | +$nodeinstanceof PrototypedArrayNode => [$this->createArraySchema($node,$definitions),staticfn () =>false,null], |
| 79 | +$nodeinstanceof ArrayNode => [$this->createObjectSchema($node,$definitions),staticfn () =>false,null], |
| 80 | +$nodeinstanceof ScalarNode => [['type' => ['boolean','number','string']],is_scalar(...),null], |
| 81 | +default => [[],staticfn () =>true,null], |
| 82 | + }; |
| 83 | + |
| 84 | +$allowNull ??= (!$node->isRequired() && ($node->hasDefaultValue() || ($nodeinstanceof VariableNode &&$node->getAllowEmptyValue()))) |
| 85 | + || ($node->hasDefaultValue() &&null ===$node->getDefaultValue()); |
| 86 | + |
| 87 | +$allowedExtraValues =$subSchemas = []; |
| 88 | +if ($nodeinstanceof 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 ($allowedExtraValuesas$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,staticfn (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 | +privatefunctioncreateArraySchema(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 | +privatefunctioncreateObjectSchema(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 | +privatefunctioncreateEnumSchema(EnumNode$node):array |
| 182 | + { |
| 183 | +return ['enum' =>array_map(staticfn ($v) =>$vinstanceof \UnitEnum ?\sprintf('!php/enum %s::%s',$v::class,$v->name) :$v,$node->getValues())]; |
| 184 | + } |
| 185 | + |
| 186 | +publicfunctioncreateNumericSchema(NumericNode$node):array |
| 187 | + { |
| 188 | +$schema = ['type' =>$nodeinstanceof 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 | +privatefunctionaddTypeToSchema(array &$schema,string$type):void |
| 202 | + { |
| 203 | +$schema['type'] = (array) ($schema['type'] ?? []); |
| 204 | +$schema['type'][] =$type; |
| 205 | + } |
| 206 | + |
| 207 | +privatefunctiongetReference(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 | +} |