Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,7 @@ private static List<String> splitAdditionalModelOption(String value) {
@Override
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
objs = super.postProcessAllModels(objs);
markModelClassRendering(objs);
if (additionalModelObjectAttributes.isEmpty()
&& additionalModelEnumAttributes.isEmpty()
&& additionalModelImports.isEmpty()) {
Expand All @@ -766,6 +767,74 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> 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<String, ModelsMap> objs) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The three new methods (markModelClassRendering, collectInlineModelRefs, isOnInlineReferenceCycle) are copied verbatim into both Swift5ClientCodegen and Swift6ClientCodegen (~70 lines each). Since both generators extend DefaultCodegen with no shared Swift parent, any future fix to cycle detection must be replicated twice and will drift. Move the graph construction and cycle detection into a shared helper (e.g. a static util or a common parent) parameterized by the models map and the useClasses/readonlyProperties flags, and have both generators call it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Swift5ClientCodegen.java, line 782:

<comment>The three new methods (markModelClassRendering, collectInlineModelRefs, isOnInlineReferenceCycle) are copied verbatim into both Swift5ClientCodegen and Swift6ClientCodegen (~70 lines each). Since both generators extend DefaultCodegen with no shared Swift parent, any future fix to cycle detection must be replicated twice and will drift. Move the graph construction and cycle detection into a shared helper (e.g. a static util or a common parent) parameterized by the models map and the useClasses/readonlyProperties flags, and have both generators call it.</comment>

<file context>
@@ -766,6 +767,73 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
+     *
+     * @param objs the models
+     */
+    private void markModelClassRendering(Map<String, ModelsMap> objs) {
+        Map<String, CodegenModel> modelsByClassname = new HashMap<>();
+        for (ModelsMap modelsMap : objs.values()) {
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that it is not pretty. It follows the existing relationship between the two generators, which share no Swift base class and already duplicate their reservedWords lists, option handling and postProcess logic - the swift6 codegen is a full copy of swift5 with its own divergences. Extracting a shared helper would be the first piece of common ground between them, which felt like a bigger call than this PR should make on its own. If maintainers want that refactor here, I am glad to do it.

Map<String, CodegenModel> modelsByClassname = new HashMap<>();
for (ModelsMap modelsMap : objs.values()) {
for (ModelMap modelMap : modelsMap.getModels()) {
CodegenModel cm = modelMap.getModel();
modelsByClassname.put(cm.classname, cm);
}
}

Map<String, Set<String>> inlineRefs = new HashMap<>();
for (CodegenModel cm : modelsByClassname.values()) {
Set<String> 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The template now renders based on the vendor extension x-swift-use-class, which markModelClassRendering only writes (never clears) when useClasses || recursive. For a spec that already declares x-swift-use-class: true on a schema while useClasses is off and the model is not on a cycle, that stored true survives and the model silently switches from a struct to a final class — a behavior change from the previous global-flag template that ignored the extension. Use a dedicated internal extension name (or always set the value explicitly, including false) so user-provided spec extensions cannot change rendering unintentionally.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Swift5ClientCodegen.java, line 806:

<comment>The template now renders based on the vendor extension `x-swift-use-class`, which `markModelClassRendering` only writes (never clears) when `useClasses || recursive`. For a spec that already declares `x-swift-use-class: true` on a schema while `useClasses` is off and the model is not on a cycle, that stored `true` survives and the model silently switches from a struct to a final class — a behavior change from the previous global-flag template that ignored the extension. Use a dedicated internal extension name (or always set the value explicitly, including false) so user-provided spec extensions cannot change rendering unintentionally.</comment>

<file context>
@@ -766,6 +767,73 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
+        for (CodegenModel cm : modelsByClassname.values()) {
+            boolean recursive = !useClasses && isOnInlineReferenceCycle(cm.classname, inlineRefs);
+            if (useClasses || recursive) {
+                cm.vendorExtensions.put("x-swift-use-class", true);
+            }
+        }
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate, and additive-only. x-swift-use-class: true on a schema is a per-model escape hatch that the global useClasses cannot express - opting a single model into class rendering - and since the extension is only ever written, never cleared, no spec that worked before this PR changes behaviour. If you would rather the extension were internal, I can namespace it (x-swift-use-class-internal) or clear it before writing; say the word.

}
}
}

private void collectInlineModelRefs(List<CodegenProperty> vars, Map<String, CodegenModel> modelsByClassname, Set<String> 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<String, Set<String>> inlineRefs) {
Deque<String> toVisit = new ArrayDeque<>(inlineRefs.getOrDefault(classname, Collections.emptySet()));
Set<String> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,7 @@ private static List<String> splitAdditionalModelOption(String value) {
@Override
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
objs = super.postProcessAllModels(objs);
markModelClassRendering(objs);
if (additionalModelObjectAttributes.isEmpty()
&& additionalModelEnumAttributes.isEmpty()
&& additionalModelImports.isEmpty()) {
Expand All @@ -814,6 +815,80 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> 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<String, ModelsMap> objs) {
Map<String, CodegenModel> modelsByClassname = new HashMap<>();
for (ModelsMap modelsMap : objs.values()) {
for (ModelMap modelMap : modelsMap.getModels()) {
CodegenModel cm = modelMap.getModel();
modelsByClassname.put(cm.classname, cm);
}
}

Map<String, Set<String>> inlineRefs = new HashMap<>();
for (CodegenModel cm : modelsByClassname.values()) {
Set<String> 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a cycle is expressed through oneOf, this flag does not break recursion because the one-of template still emits a non-indirect enum. Handle these cycles with an indirect representation (or a separate cycle strategy) instead of marking them as fixed by the class extension.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/Swift6ClientCodegen.java, line 854:

<comment>When a cycle is expressed through `oneOf`, this flag does not break recursion because the one-of template still emits a non-`indirect` enum. Handle these cycles with an indirect representation (or a separate cycle strategy) instead of marking them as fixed by the class extension.</comment>

<file context>
@@ -814,6 +815,79 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
+        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) {
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think this holds. A cycle running through a oneOf enum (struct A -> enum E -> A) is broken by A becoming a class: E's associated value is then a reference, so E has finite size and so does A. That is exactly why the composedSchemas oneOf/anyOf references are collected as edges - they make A get marked. An enum-only cycle with no object model in between has no struct to convert, but it also cannot be expressed: something has to carry the reference inline. If you have a spec shape where a oneOf cycle survives this, I will happily take it as a fixture - I could not construct one.

}
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<CodegenProperty> vars, Map<String, CodegenModel> modelsByClassname, Set<String> 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<String, Set<String>> inlineRefs) {
Deque<String> toVisit = new ArrayDeque<>(inlineRefs.getOrDefault(classname, Collections.emptySet()));
Set<String> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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}}

Expand Down Expand Up @@ -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}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the default hashableModels setting is used, a newly class-rendered recursive model still gets recursive == and hash(into:) implementations. Hashing or comparing a cyclic instance then overflows the stack; suppress Hashable for cycle models or generate cycle-safe identity semantics.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/swift5/modelObject.mustache, line 124:

<comment>When the default `hashableModels` setting is used, a newly class-rendered recursive model still gets recursive `==` and `hash(into:)` implementations. Hashing or comparing a cyclic instance then overflows the stack; suppress Hashable for cycle models or generate cycle-safe identity semantics.</comment>

<file context>
@@ -121,7 +121,7 @@
         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 {
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one predates the PR rather than being introduced by it: with useClasses=true, every model is already a class today and the generated ==/hash(into:) already recurse over whatever object graph they are given. Wire data cannot express a cycle - JSON is a tree - so a cyclic instance only exists if a caller builds one by hand, and this change does not make that any more reachable than useClasses already does. Switching these models to identity-based equality would be a real behavioural departure from that precedent, so I would rather not fold it in here; happy to file it separately if you think the useClasses case deserves a fix.


{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} static func == (lhs: {{classname}}, rhs: {{classname}}) -> Bool {
{{#allVars}}
Expand All @@ -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}}
}
Original file line number Diff line number Diff line change
@@ -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}}

Expand Down Expand Up @@ -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}}
Expand All @@ -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}}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Loading