diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Swift5ClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Swift5ClientCodegen.java index 92224fc5a338..d4bf79eb06ba 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Swift5ClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Swift5ClientCodegen.java @@ -746,6 +746,7 @@ private static List splitAdditionalModelOption(String value) { @Override public Map postProcessAllModels(Map objs) { objs = super.postProcessAllModels(objs); + markModelClassRendering(objs); if (additionalModelObjectAttributes.isEmpty() && additionalModelEnumAttributes.isEmpty() && additionalModelImports.isEmpty()) { @@ -766,6 +767,74 @@ public Map postProcessAllModels(Map objs) return objs; } + + /** + * A struct that stores itself inline - through any chain of model-typed properties, + * Optional included - has infinite size and does not compile ("value type cannot have a + * stored property that recursively contains it"). Containers store their elements on the + * heap and break the recursion, so only bare model-to-model properties form the edges. + * Every model on such a reference cycle is generated as a final class instead: heap + * allocation provides the indirection the struct cannot have, and the wire format is + * unchanged. See https://github.com/OpenAPITools/openapi-generator/issues/15240. + * + * @param objs the models + */ + private void markModelClassRendering(Map objs) { + Map modelsByClassname = new HashMap<>(); + for (ModelsMap modelsMap : objs.values()) { + for (ModelMap modelMap : modelsMap.getModels()) { + CodegenModel cm = modelMap.getModel(); + modelsByClassname.put(cm.classname, cm); + } + } + + Map> inlineRefs = new HashMap<>(); + for (CodegenModel cm : modelsByClassname.values()) { + Set refs = new LinkedHashSet<>(); + collectInlineModelRefs(cm.allVars, modelsByClassname, refs); + if (cm.getComposedSchemas() != null) { + // oneOf/anyOf render as enums with inline associated values, so they carry the + // recursion; allOf is flattened into allVars and is deliberately not an edge + collectInlineModelRefs(cm.getComposedSchemas().getOneOf(), modelsByClassname, refs); + collectInlineModelRefs(cm.getComposedSchemas().getAnyOf(), modelsByClassname, refs); + } + inlineRefs.put(cm.classname, refs); + } + + for (CodegenModel cm : modelsByClassname.values()) { + boolean recursive = !useClasses && isOnInlineReferenceCycle(cm.classname, inlineRefs); + if (useClasses || recursive) { + cm.vendorExtensions.put("x-swift-use-class", true); + } + } + } + + private void collectInlineModelRefs(List vars, Map modelsByClassname, Set refs) { + if (vars == null) { + return; + } + for (CodegenProperty var : vars) { + if (!var.isContainer && var.complexType != null && modelsByClassname.containsKey(var.complexType)) { + refs.add(var.complexType); + } + } + } + + private boolean isOnInlineReferenceCycle(String classname, Map> inlineRefs) { + Deque toVisit = new ArrayDeque<>(inlineRefs.getOrDefault(classname, Collections.emptySet())); + Set visited = new HashSet<>(); + while (!toVisit.isEmpty()) { + String current = toVisit.pop(); + if (classname.equals(current)) { + return true; + } + if (visited.add(current)) { + toVisit.addAll(inlineRefs.getOrDefault(current, Collections.emptySet())); + } + } + return false; + } + @Override protected boolean isReservedWord(String word) { return word != null && reservedWords.contains(word); //don't lowercase as super does diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Swift6ClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Swift6ClientCodegen.java index 77394f42b70f..99b8f014c2c7 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Swift6ClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Swift6ClientCodegen.java @@ -794,6 +794,7 @@ private static List splitAdditionalModelOption(String value) { @Override public Map postProcessAllModels(Map objs) { objs = super.postProcessAllModels(objs); + markModelClassRendering(objs); if (additionalModelObjectAttributes.isEmpty() && additionalModelEnumAttributes.isEmpty() && additionalModelImports.isEmpty()) { @@ -814,6 +815,80 @@ public Map postProcessAllModels(Map objs) return objs; } + + /** + * A struct that stores itself inline - through any chain of model-typed properties, + * Optional included - has infinite size and does not compile ("value type cannot have a + * stored property that recursively contains it"). Containers store their elements on the + * heap and break the recursion, so only bare model-to-model properties form the edges. + * Every model on such a reference cycle is generated as a final class instead: heap + * allocation provides the indirection the struct cannot have, and the wire format is + * unchanged. See https://github.com/OpenAPITools/openapi-generator/issues/15240. + * + * @param objs the models + */ + private void markModelClassRendering(Map objs) { + Map modelsByClassname = new HashMap<>(); + for (ModelsMap modelsMap : objs.values()) { + for (ModelMap modelMap : modelsMap.getModels()) { + CodegenModel cm = modelMap.getModel(); + modelsByClassname.put(cm.classname, cm); + } + } + + Map> inlineRefs = new HashMap<>(); + for (CodegenModel cm : modelsByClassname.values()) { + Set refs = new LinkedHashSet<>(); + collectInlineModelRefs(cm.allVars, modelsByClassname, refs); + if (cm.getComposedSchemas() != null) { + // oneOf/anyOf render as enums with inline associated values, so they carry the + // recursion; allOf is flattened into allVars and is deliberately not an edge + collectInlineModelRefs(cm.getComposedSchemas().getOneOf(), modelsByClassname, refs); + collectInlineModelRefs(cm.getComposedSchemas().getAnyOf(), modelsByClassname, refs); + } + inlineRefs.put(cm.classname, refs); + } + + for (CodegenModel cm : modelsByClassname.values()) { + boolean recursive = !useClasses && isOnInlineReferenceCycle(cm.classname, inlineRefs); + if (useClasses || recursive) { + cm.vendorExtensions.put("x-swift-use-class", true); + } + if ((useClasses && readonlyProperties) || recursive) { + // a struct embedding one of these classes is still declared Sendable, so the + // class must conform; a recursion-breaking class is treated as unchecked the + // way readonlyProperties classes already are + cm.vendorExtensions.put("x-swift-unchecked-sendable", true); + } + } + } + + private void collectInlineModelRefs(List vars, Map modelsByClassname, Set refs) { + if (vars == null) { + return; + } + for (CodegenProperty var : vars) { + if (!var.isContainer && var.complexType != null && modelsByClassname.containsKey(var.complexType)) { + refs.add(var.complexType); + } + } + } + + private boolean isOnInlineReferenceCycle(String classname, Map> inlineRefs) { + Deque toVisit = new ArrayDeque<>(inlineRefs.getOrDefault(classname, Collections.emptySet())); + Set visited = new HashSet<>(); + while (!toVisit.isEmpty()) { + String current = toVisit.pop(); + if (classname.equals(current)) { + return true; + } + if (visited.add(current)) { + toVisit.addAll(inlineRefs.getOrDefault(current, Collections.emptySet())); + } + } + return false; + } + @Override protected boolean isReservedWord(String word) { return word != null && reservedWords.contains(word); //don't lowercase as super does diff --git a/modules/openapi-generator/src/main/resources/swift5/modelObject.mustache b/modules/openapi-generator/src/main/resources/swift5/modelObject.mustache index b6759a6a0e4b..7e107cb08f5c 100644 --- a/modules/openapi-generator/src/main/resources/swift5/modelObject.mustache +++ b/modules/openapi-generator/src/main/resources/swift5/modelObject.mustache @@ -1,5 +1,5 @@ {{#additionalModelObjectAttributes}}{{{.}}} -{{/additionalModelObjectAttributes}}{{^objcCompatible}}{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} {{#useClasses}}final class{{/useClasses}}{{^useClasses}}struct{{/useClasses}} {{{classname}}}: {{#useVapor}}Content{{/useVapor}}{{^useVapor}}Codable{{#useJsonEncodable}}, JSONEncodable{{/useJsonEncodable}}{{/useVapor}}{{#vendorExtensions.x-swift-hashable}}, Hashable{{/vendorExtensions.x-swift-hashable}} { +{{/additionalModelObjectAttributes}}{{^objcCompatible}}{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} {{#vendorExtensions.x-swift-use-class}}final class{{/vendorExtensions.x-swift-use-class}}{{^vendorExtensions.x-swift-use-class}}struct{{/vendorExtensions.x-swift-use-class}} {{{classname}}}: {{#useVapor}}Content{{/useVapor}}{{^useVapor}}Codable{{#useJsonEncodable}}, JSONEncodable{{/useJsonEncodable}}{{/useVapor}}{{#vendorExtensions.x-swift-hashable}}, Hashable{{/vendorExtensions.x-swift-hashable}} { {{/objcCompatible}}{{#objcCompatible}}@objcMembers {{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} class {{classname}}: NSObject, Codable{{#useJsonEncodable}}, JSONEncodable{{/useJsonEncodable}} { {{/objcCompatible}} @@ -121,7 +121,7 @@ {{/allVars}} let additionalPropertiesContainer = try decoder.container(keyedBy: String.self) additionalProperties = try additionalPropertiesContainer.decodeMap({{{additionalPropertiesType}}}.self, excludedKeys: nonAdditionalPropertyKeys) - }{{/additionalPropertiesType}}{{/generateModelAdditionalProperties}}{{^objcCompatible}}{{#useClasses}}{{#vendorExtensions.x-swift-hashable}} + }{{/additionalPropertiesType}}{{/generateModelAdditionalProperties}}{{^objcCompatible}}{{#vendorExtensions.x-swift-use-class}}{{#vendorExtensions.x-swift-hashable}} {{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} static func == (lhs: {{classname}}, rhs: {{classname}}) -> Bool { {{#allVars}} @@ -135,5 +135,5 @@ hasher.combine({{{name}}}{{^vendorExtensions.x-null-encodable}}{{^required}}?{{/required}}{{/vendorExtensions.x-null-encodable}}.hashValue) {{/allVars}} {{#generateModelAdditionalProperties}}{{#additionalPropertiesType}}hasher.combine(additionalProperties.hashValue){{/additionalPropertiesType}}{{/generateModelAdditionalProperties}} - }{{/vendorExtensions.x-swift-hashable}}{{/useClasses}}{{/objcCompatible}} + }{{/vendorExtensions.x-swift-hashable}}{{/vendorExtensions.x-swift-use-class}}{{/objcCompatible}} } diff --git a/modules/openapi-generator/src/main/resources/swift6/modelObject.mustache b/modules/openapi-generator/src/main/resources/swift6/modelObject.mustache index c4677923979d..445826d40146 100644 --- a/modules/openapi-generator/src/main/resources/swift6/modelObject.mustache +++ b/modules/openapi-generator/src/main/resources/swift6/modelObject.mustache @@ -1,5 +1,5 @@ {{#additionalModelObjectAttributes}}{{{.}}} -{{/additionalModelObjectAttributes}}{{^objcCompatible}}{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} {{#useClasses}}final class{{/useClasses}}{{^useClasses}}struct{{/useClasses}} {{{classname}}}: {{^useClasses}}Sendable, {{/useClasses}}{{#useClasses}}{{#readonlyProperties}}@unchecked Sendable, {{/readonlyProperties}}{{/useClasses}}{{#useVapor}}Content{{/useVapor}}{{^useVapor}}Codable{{/useVapor}}{{#vendorExtensions.x-swift-hashable}}, Hashable{{/vendorExtensions.x-swift-hashable}} { +{{/additionalModelObjectAttributes}}{{^objcCompatible}}{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} {{#vendorExtensions.x-swift-use-class}}final class{{/vendorExtensions.x-swift-use-class}}{{^vendorExtensions.x-swift-use-class}}struct{{/vendorExtensions.x-swift-use-class}} {{{classname}}}: {{^vendorExtensions.x-swift-use-class}}Sendable, {{/vendorExtensions.x-swift-use-class}}{{#vendorExtensions.x-swift-unchecked-sendable}}@unchecked Sendable, {{/vendorExtensions.x-swift-unchecked-sendable}}{{#useVapor}}Content{{/useVapor}}{{^useVapor}}Codable{{/useVapor}}{{#vendorExtensions.x-swift-hashable}}, Hashable{{/vendorExtensions.x-swift-hashable}} { {{/objcCompatible}}{{#objcCompatible}}@objcMembers {{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} final class {{classname}}: NSObject, Codable, @unchecked Sendable { {{/objcCompatible}} @@ -121,7 +121,7 @@ {{/allVars}} let additionalPropertiesContainer = try decoder.container(keyedBy: String.self) additionalProperties = try additionalPropertiesContainer.decodeMap({{{additionalPropertiesType}}}.self, excludedKeys: nonAdditionalPropertyKeys) - }{{/additionalPropertiesType}}{{/generateModelAdditionalProperties}}{{^objcCompatible}}{{#useClasses}}{{#vendorExtensions.x-swift-hashable}} + }{{/additionalPropertiesType}}{{/generateModelAdditionalProperties}}{{^objcCompatible}}{{#vendorExtensions.x-swift-use-class}}{{#vendorExtensions.x-swift-hashable}} {{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} static func == (lhs: {{classname}}, rhs: {{classname}}) -> Bool { {{#allVars}} @@ -135,5 +135,5 @@ hasher.combine({{{name}}}{{^vendorExtensions.x-null-encodable}}{{^required}}?{{/required}}{{/vendorExtensions.x-null-encodable}}.hashValue) {{/allVars}} {{#generateModelAdditionalProperties}}{{#additionalPropertiesType}}hasher.combine(additionalProperties.hashValue){{/additionalPropertiesType}}{{/generateModelAdditionalProperties}} - }{{/vendorExtensions.x-swift-hashable}}{{/useClasses}}{{/objcCompatible}} + }{{/vendorExtensions.x-swift-hashable}}{{/vendorExtensions.x-swift-use-class}}{{/objcCompatible}} } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/swift5/Swift5ClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/swift5/Swift5ClientCodegenTest.java index 782a5d95d57e..a9e64c40716c 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/swift5/Swift5ClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/swift5/Swift5ClientCodegenTest.java @@ -266,6 +266,28 @@ public void testPodAuthors() throws Exception { Assert.assertEquals(podAuthors, openAPIDevs); } + @Test(description = "models on an inline reference cycle become classes, everything else stays a struct") + public void testRecursiveModelsBecomeClasses() throws IOException { + Path target = Files.createTempDirectory("test"); + target.toFile().deleteOnExit(); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("swift5") + .setInputSpec("src/test/resources/3_0/swift/recursive-models.yaml") + .setOutputDir(target.toAbsolutePath().toString()); + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + Path models = target.resolve("OpenAPIClient/Classes/OpenAPIs/Models"); + // a struct that stores itself inline has infinite size and does not compile (#15240): + // the self-referencing model and both halves of the mutual cycle become final classes + TestUtils.assertFileContains(models.resolve("ContactInfo.swift"), "public final class ContactInfo:"); + TestUtils.assertFileContains(models.resolve("NodeA.swift"), "public final class NodeA:"); + TestUtils.assertFileContains(models.resolve("NodeB.swift"), "public final class NodeB:"); + // embedding a cyclic class costs nothing, and containers already give heap + // indirection - these stay structs + TestUtils.assertFileContains(models.resolve("DomainInfo.swift"), "public struct DomainInfo:"); + TestUtils.assertFileContains(models.resolve("Category.swift"), "public struct Category:"); + } + @Test(description = "Bug example code generation", enabled = true) public void crashSwift5ExampleCodeGenerationStackOverflowTest() throws IOException { //final OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/bugs/Swift5CodeGenerationStackOverflow#2966.yaml"); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/swift6/Swift6ClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/swift6/Swift6ClientCodegenTest.java index 3fe2f07d3b0f..6c94b5b730ad 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/swift6/Swift6ClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/swift6/Swift6ClientCodegenTest.java @@ -52,6 +52,28 @@ public void testToRegularExpressionRemainsValidInSwiftStringLiteral() throws Exc Assert.assertEquals(swiftCodegen.toRegularExpression("/[a-z]/i"), "/[a-z]/i"); } + @Test(description = "models on an inline reference cycle become classes, everything else stays a struct") + public void testRecursiveModelsBecomeClasses() throws IOException { + Path target = Files.createTempDirectory("test"); + target.toFile().deleteOnExit(); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("swift6") + .setInputSpec("src/test/resources/3_0/swift/recursive-models.yaml") + .setOutputDir(target.toAbsolutePath().toString()); + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + Path models = target.resolve("Sources/OpenAPIClient/Models"); + // a struct that stores itself inline has infinite size and does not compile (#15240): + // the self-referencing model and both halves of the mutual cycle become final classes + TestUtils.assertFileContains(models.resolve("ContactInfo.swift"), "public final class ContactInfo: @unchecked Sendable,"); + TestUtils.assertFileContains(models.resolve("NodeA.swift"), "public final class NodeA: @unchecked Sendable,"); + TestUtils.assertFileContains(models.resolve("NodeB.swift"), "public final class NodeB: @unchecked Sendable,"); + // embedding a cyclic class costs nothing, and containers already give heap + // indirection - these stay structs + TestUtils.assertFileContains(models.resolve("DomainInfo.swift"), "public struct DomainInfo: Sendable,"); + TestUtils.assertFileContains(models.resolve("Category.swift"), "public struct Category: Sendable,"); + } + @Test(enabled = true) public void testCapitalizedReservedWord() throws Exception { Assert.assertEquals(swiftCodegen.toEnumVarName("AS", null), "_as"); diff --git a/modules/openapi-generator/src/test/resources/3_0/swift/recursive-models.yaml b/modules/openapi-generator/src/test/resources/3_0/swift/recursive-models.yaml new file mode 100644 index 000000000000..f917b4a32296 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/swift/recursive-models.yaml @@ -0,0 +1,50 @@ +openapi: 3.0.3 +info: + title: recursive models + version: 1.0.0 +paths: + /contacts: + get: + operationId: getContact + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/DomainInfo' +components: + schemas: + ContactInfo: + type: object + properties: + name: + type: string + internationalizedPostalInfo: + $ref: '#/components/schemas/ContactInfo' + DomainInfo: + type: object + properties: + domainName: + type: string + registrant: + $ref: '#/components/schemas/ContactInfo' + NodeA: + type: object + properties: + b: + $ref: '#/components/schemas/NodeB' + NodeB: + type: object + properties: + a: + $ref: '#/components/schemas/NodeA' + Category: + type: object + properties: + name: + type: string + children: + type: array + items: + $ref: '#/components/schemas/Category'