2222use ApiPlatform \Metadata \Property \Factory \PropertyNameCollectionFactoryInterface ;
2323use ApiPlatform \Metadata \Resource \Factory \ResourceMetadataCollectionFactoryInterface ;
2424use ApiPlatform \Metadata \ResourceClassResolverInterface ;
25+ use ApiPlatform \Metadata \Util \ClassDiscriminatorHelper ;
2526use ApiPlatform \Metadata \Util \TypeHelper ;
27+ use Symfony \Component \Serializer \Mapping \ClassDiscriminatorResolverInterface ;
2628use Symfony \Component \Serializer \NameConverter \NameConverterInterface ;
2729use Symfony \Component \Serializer \Normalizer \AbstractNormalizer ;
2830use Symfony \Component \TypeInfo \Type \BuiltinType ;
@@ -48,7 +50,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI
4850 public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name ' ;
4951 public const PARTIAL_UPDATE = 'partial_update ' ;
5052
51- public function __construct (ResourceMetadataCollectionFactoryInterface $ resourceMetadataFactory , private readonly PropertyNameCollectionFactoryInterface $ propertyNameCollectionFactory , private readonly PropertyMetadataFactoryInterface $ propertyMetadataFactory , private readonly ?NameConverterInterface $ nameConverter = null , ?ResourceClassResolverInterface $ resourceClassResolver = null , private ?DefinitionNameFactoryInterface $ definitionNameFactory = null )
53+ public function __construct (ResourceMetadataCollectionFactoryInterface $ resourceMetadataFactory , private readonly PropertyNameCollectionFactoryInterface $ propertyNameCollectionFactory , private readonly PropertyMetadataFactoryInterface $ propertyMetadataFactory , private readonly ?NameConverterInterface $ nameConverter = null , ?ResourceClassResolverInterface $ resourceClassResolver = null , private ?DefinitionNameFactoryInterface $ definitionNameFactory = null , private readonly ? ClassDiscriminatorResolverInterface $ classDiscriminatorResolver = null )
5254 {
5355 if (!$ definitionNameFactory ) {
5456 $ this ->definitionNameFactory = new DefinitionNameFactory ();
@@ -152,22 +154,140 @@ public function buildSchema(string $className, string $format = 'json', string $
152154 }
153155
154156 $ options = ['schema_type ' => $ type ] + $ this ->getFactoryOptions ($ serializerContext , $ validationGroups , $ operation instanceof HttpOperation ? $ operation : null );
155- foreach ($ this ->propertyNameCollectionFactory ->create ($ inputOrOutputClass , $ options ) as $ propertyName ) {
156- $ propertyMetadata = $ this ->propertyMetadataFactory ->create ($ inputOrOutputClass , $ propertyName , $ options );
157+ $ this ->buildDefinitionProperties ($ schema , $ definition , $ definitionName , $ inputOrOutputClass , $ options , $ serializerContext , $ format , $ type , $ isPartialUpdate );
158+
159+ // Subtypes explicitly declared in a serializer discriminator map are documented using "oneOf" and an OpenAPI "discriminator"
160+ $ this ->buildDiscriminatedDefinitions ($ schema , $ definition , $ className , $ inputOrOutputClass , $ definitionFormat , $ operation , $ options , $ serializerContext , $ serializerContext + [
161+ 'schema_type ' => $ type ,
162+ self ::PARTIAL_UPDATE => $ isPartialUpdate && !$ isJsonMergePatch ,
163+ ], $ format , $ type , $ isPartialUpdate );
164+
165+ return $ schema ;
166+ }
167+
168+ /**
169+ * @param \ArrayObject<string, mixed> $definition
170+ */
171+ private function buildDefinitionProperties (Schema $ schema , \ArrayObject $ definition , string $ definitionName , string $ class , array $ options , array $ serializerContext , string $ format , string $ type , bool $ isPartialUpdate ): void
172+ {
173+ foreach ($ this ->propertyNameCollectionFactory ->create ($ class , $ options ) as $ propertyName ) {
174+ $ propertyMetadata = $ this ->propertyMetadataFactory ->create ($ class , $ propertyName , $ options );
157175
158176 if (false === $ propertyMetadata ->isReadable () && false === $ propertyMetadata ->isWritable ()) {
159177 continue ;
160178 }
161179
162- $ normalizedPropertyName = $ this ->nameConverter ? $ this ->nameConverter ->normalize ($ propertyName , $ inputOrOutputClass , $ format , $ serializerContext ) : $ propertyName ;
180+ $ normalizedPropertyName = $ this ->nameConverter ? $ this ->nameConverter ->normalize ($ propertyName , $ class , $ format , $ serializerContext ) : $ propertyName ;
163181 if ($ propertyMetadata ->isRequired () && !$ isPartialUpdate ) {
164182 $ definition ['required ' ][] = $ normalizedPropertyName ;
165183 }
166184
167185 $ this ->buildPropertySchema ($ schema , $ definitionName , $ normalizedPropertyName , $ propertyMetadata , $ serializerContext , $ format , $ type );
168186 }
187+ }
169188
170- return $ schema ;
189+ /**
190+ * @param \ArrayObject<string, mixed> $definition
191+ * @param array<string, array<string, mixed>> $parentTypeProperties type properties of the outer discriminator maps the class belongs to
192+ */
193+ private function buildDiscriminatedDefinitions (Schema $ schema , \ArrayObject $ definition , string $ className , string $ class , string $ definitionFormat , ?Operation $ operation , array $ options , array $ serializerContext , array $ definitionNameContext , string $ format , string $ type , bool $ isPartialUpdate , array $ parentTypeProperties = []): void
194+ {
195+ if (null === $ this ->classDiscriminatorResolver || null === $ mapping = $ this ->classDiscriminatorResolver ->getMappingForClass ($ class )) {
196+ return ;
197+ }
198+
199+ $ typeProperty = $ mapping ->getTypeProperty ();
200+ $ definitions = $ schema ->getDefinitions ();
201+ $ prefix = $ this ->getSchemaUriPrefix ($ schema ->getVersion ());
202+ $ discriminatorMapping = [];
203+ $ oneOf = [];
204+
205+ foreach (ClassDiscriminatorHelper ::getDirectSubtypes ($ mapping , $ class ) as $ typeValue => $ subClass ) {
206+ $ subDefinitionName = $ this ->definitionNameFactory ->create ($ className , $ definitionFormat , $ subClass , $ operation , $ definitionNameContext );
207+ $ discriminatorMapping [$ typeValue ] = $ oneOf []['$ref ' ] = $ prefix .$ subDefinitionName ;
208+
209+ if (isset ($ definitions [$ subDefinitionName ])) {
210+ continue ;
211+ }
212+
213+ /** @var \ArrayObject<string, mixed> $subDefinition */
214+ $ subDefinition = new \ArrayObject (['type ' => 'object ' ]);
215+ $ definitions [$ subDefinitionName ] = $ subDefinition ;
216+ if (isset ($ definition ['additionalProperties ' ])) {
217+ $ subDefinition ['additionalProperties ' ] = $ definition ['additionalProperties ' ];
218+ }
219+
220+ $ this ->buildDefinitionProperties ($ schema , $ subDefinition , $ subDefinitionName , $ subClass , $ options , $ serializerContext , $ format , $ type , $ isPartialUpdate );
221+
222+ // The type properties of the outer maps are part of the payload of a nested subtype too
223+ $ typeProperties = $ parentTypeProperties + [$ typeProperty => ['type ' => 'string ' , 'enum ' => [(string ) $ typeValue ]]];
224+ $ this ->addTypeProperties ($ subDefinition , $ typeProperties , $ isPartialUpdate );
225+
226+ $ this ->buildDiscriminatedDefinitions ($ schema , $ subDefinition , $ className , $ subClass , $ definitionFormat , $ operation , $ options , $ serializerContext , $ definitionNameContext , $ format , $ type , $ isPartialUpdate , $ typeProperties );
227+ }
228+
229+ $ ownTypeValues = ClassDiscriminatorHelper ::getOwnTypes ($ mapping , $ class );
230+ foreach ($ ownTypeValues as $ typeValue ) {
231+ $ discriminatorMapping [$ typeValue ] = $ prefix .$ this ->definitionNameFactory ->create ($ className , $ definitionFormat , $ class , $ operation , $ definitionNameContext );
232+ }
233+
234+ if (!$ discriminatorMapping ) {
235+ return ;
236+ }
237+
238+ if ($ oneOf ) {
239+ // An instance of the class itself is described by its own branch: it carries its own type and none of the subtype properties
240+ if ($ ownTypeValues ) {
241+ $ ownBranch = new \ArrayObject (['type ' => 'object ' ]);
242+ foreach (['properties ' , 'required ' , 'additionalProperties ' ] as $ keyword ) {
243+ if (isset ($ definition [$ keyword ])) {
244+ $ ownBranch [$ keyword ] = $ definition [$ keyword ];
245+ }
246+ }
247+
248+ $ this ->addTypeProperties ($ ownBranch , [$ typeProperty => ['type ' => 'string ' , 'enum ' => $ ownTypeValues ]], $ isPartialUpdate || \in_array ($ mapping ->getDefaultType (), $ ownTypeValues , true ));
249+ array_unshift ($ oneOf , $ ownBranch ->getArrayCopy ());
250+ }
251+
252+ $ definition ['oneOf ' ] = $ oneOf ;
253+
254+ // "additionalProperties" ignores the properties declared in the "oneOf" branches, they are restricted by the branches instead
255+ unset($ definition ['additionalProperties ' ]);
256+ }
257+
258+ // An instance of the class itself carries its own type
259+ if ($ ownTypeValues && !isset ($ definition ['properties ' ][$ typeProperty ])) {
260+ if ($ oneOf ) {
261+ $ properties = $ definition ['properties ' ] ?? [];
262+ $ properties [$ typeProperty ] = ['type ' => 'string ' , 'enum ' => array_map ('strval ' , array_keys ($ discriminatorMapping ))];
263+ $ definition ['properties ' ] = $ properties ;
264+ } else {
265+ $ this ->addTypeProperties ($ definition , [$ typeProperty => ['type ' => 'string ' , 'enum ' => $ ownTypeValues ]], $ isPartialUpdate || \in_array ($ mapping ->getDefaultType (), $ ownTypeValues , true ));
266+ }
267+ }
268+
269+ $ definition ['discriminator ' ] = ['propertyName ' => $ typeProperty , 'mapping ' => $ discriminatorMapping ];
270+ }
271+
272+ /**
273+ * @param \ArrayObject<string, mixed> $definition
274+ * @param array<string, array<string, mixed>> $typeProperties
275+ */
276+ private function addTypeProperties (\ArrayObject $ definition , array $ typeProperties , bool $ optional ): void
277+ {
278+ $ properties = $ definition ['properties ' ] ?? [];
279+ $ required = $ definition ['required ' ] ?? [];
280+ foreach ($ typeProperties as $ name => $ typePropertySchema ) {
281+ $ properties [$ name ] = $ typePropertySchema ;
282+ if (!$ optional && !\in_array ($ name , $ required , true )) {
283+ $ required [] = $ name ;
284+ }
285+ }
286+
287+ $ definition ['properties ' ] = $ properties ;
288+ if ($ required ) {
289+ $ definition ['required ' ] = $ required ;
290+ }
171291 }
172292
173293 private function buildPropertySchema (Schema $ schema , string $ definitionName , string $ normalizedPropertyName , ApiProperty $ propertyMetadata , array $ serializerContext , string $ format , string $ parentType ): void
0 commit comments