From 6f6f05cb28ab178e9de8093b8d47041e60b89523 Mon Sep 17 00:00:00 2001 From: Jason Ak Date: Thu, 8 Jan 2026 17:06:50 +0100 Subject: [PATCH 1/4] Fix Swift oneOf discriminator decoding with enumUnknownDefaultCase This commit implements discriminator-first decoding for oneOf schemas in Swift5 and Swift6 generators to fix the bug where enumUnknownDefaultCase=true breaks discriminator-based routing. Problem: When enumUnknownDefaultCase=true is set, discriminator fields have an unknownDefaultOpenApi fallback case. With the previous sequential try? decoding approach, the first variant always matched because the discriminator field would accept any value via the fallback, causing incorrect type selection and data corruption. Solution: - Implement discriminator-first decoding strategy - When a discriminator exists, read its value FIRST using a keyed container - Switch on the discriminator value to route directly to the correct variant - Only use sequential try? decoding when NO discriminator is present Changes: - Modified swift5/modelOneOf.mustache to add discriminator support - Modified swift6/modelOneOf.mustache to add discriminator support - Added proper error messages that include the actual discriminator value - Fixed encoding bug: removed unused parameter from unknownDefaultOpenApi case - Maintained backward compatibility for non-discriminator oneOf schemas - Updated samples to reflect template changes Benefits: - Fixes discriminator-based oneOf decoding when enumUnknownDefaultCase=true - Better performance: O(1) switch vs O(n) sequential tries - Clearer error messages with actual discriminator values - No breaking changes for existing code Fixes #7549 --- .../main/resources/swift5/modelOneOf.mustache | 35 ++++++++++++++++++- .../main/resources/swift6/modelOneOf.mustache | 35 ++++++++++++++++++- .../Classes/OpenAPIs/Models/Fruit.swift | 2 ++ .../Classes/OpenAPIs/Models/Fruit.swift | 2 ++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/swift5/modelOneOf.mustache b/modules/openapi-generator/src/main/resources/swift5/modelOneOf.mustache index 312cda2f1ccd..bb3f1d7d4756 100644 --- a/modules/openapi-generator/src/main/resources/swift5/modelOneOf.mustache +++ b/modules/openapi-generator/src/main/resources/swift5/modelOneOf.mustache @@ -14,13 +14,45 @@ try container.encode(value) {{/oneOf}} {{#oneOfUnknownDefaultCase}} - case unknownDefaultOpenApi(let type): + case .unknownDefaultOpenApi: try container.encodeNil() {{/oneOfUnknownDefaultCase}} } } + {{#discriminator}} + private enum DiscriminatorCodingKey: String, CodingKey { + case {{discriminator.propertyName}} = "{{discriminator.propertyBaseName}}" + } + {{/discriminator}} + public init(from decoder: Decoder) throws { + {{#discriminator}} + // Discriminator-based decoding: read discriminator value first, then decode the correct variant + let keyedContainer = try decoder.container(keyedBy: DiscriminatorCodingKey.self) + let discriminatorValue = try keyedContainer.decode(String.self, forKey: .{{discriminator.propertyName}}) + + switch discriminatorValue { + {{#discriminator.mappedModels}} + case "{{mappingName}}": + self = .type{{modelName}}(try {{modelName}}(from: decoder)) + {{/discriminator.mappedModels}} + default: + {{#oneOfUnknownDefaultCase}} + self = .unknownDefaultOpenApi + {{/oneOfUnknownDefaultCase}} + {{^oneOfUnknownDefaultCase}} + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown discriminator value '\(discriminatorValue)' for {{classname}}" + ) + ) + {{/oneOfUnknownDefaultCase}} + } + {{/discriminator}} + {{^discriminator}} + // No discriminator: try each type sequentially let container = try decoder.singleValueContainer() {{#oneOf}} {{#-first}} @@ -39,5 +71,6 @@ throw DecodingError.typeMismatch(Self.Type.self, .init(codingPath: decoder.codingPath, debugDescription: "Unable to decode instance of {{classname}}")) {{/oneOfUnknownDefaultCase}} } + {{/discriminator}} } } diff --git a/modules/openapi-generator/src/main/resources/swift6/modelOneOf.mustache b/modules/openapi-generator/src/main/resources/swift6/modelOneOf.mustache index 06205f18249c..9116be67f928 100644 --- a/modules/openapi-generator/src/main/resources/swift6/modelOneOf.mustache +++ b/modules/openapi-generator/src/main/resources/swift6/modelOneOf.mustache @@ -14,13 +14,45 @@ try container.encode(value) {{/oneOf}} {{#oneOfUnknownDefaultCase}} - case unknownDefaultOpenApi(let type): + case .unknownDefaultOpenApi: try container.encodeNil() {{/oneOfUnknownDefaultCase}} } } + {{#discriminator}} + private enum DiscriminatorCodingKey: String, CodingKey { + case {{discriminator.propertyName}} = "{{discriminator.propertyBaseName}}" + } + {{/discriminator}} + public init(from decoder: Decoder) throws { + {{#discriminator}} + // Discriminator-based decoding: read discriminator value first, then decode the correct variant + let keyedContainer = try decoder.container(keyedBy: DiscriminatorCodingKey.self) + let discriminatorValue = try keyedContainer.decode(String.self, forKey: .{{discriminator.propertyName}}) + + switch discriminatorValue { + {{#discriminator.mappedModels}} + case "{{mappingName}}": + self = .type{{#transformArrayType}}{{modelName}}{{/transformArrayType}}(try {{modelName}}(from: decoder)) + {{/discriminator.mappedModels}} + default: + {{#oneOfUnknownDefaultCase}} + self = .unknownDefaultOpenApi + {{/oneOfUnknownDefaultCase}} + {{^oneOfUnknownDefaultCase}} + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown discriminator value '\(discriminatorValue)' for {{classname}}" + ) + ) + {{/oneOfUnknownDefaultCase}} + } + {{/discriminator}} + {{^discriminator}} + // No discriminator: try each type sequentially let container = try decoder.singleValueContainer() {{#oneOf}} {{#-first}} @@ -39,5 +71,6 @@ throw DecodingError.typeMismatch(Self.Type.self, .init(codingPath: decoder.codingPath, debugDescription: "Unable to decode instance of {{classname}}")) {{/oneOfUnknownDefaultCase}} } + {{/discriminator}} } } diff --git a/samples/client/petstore/swift5/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift b/samples/client/petstore/swift5/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift index 0a7c58c949d8..89abe8b3d6cd 100644 --- a/samples/client/petstore/swift5/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift +++ b/samples/client/petstore/swift5/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift @@ -27,7 +27,9 @@ public enum Fruit: Codable, JSONEncodable, Hashable { } } + public init(from decoder: Decoder) throws { + // No discriminator: try each type sequentially let container = try decoder.singleValueContainer() if let value = try? container.decode(Apple.self) { self = .typeApple(value) diff --git a/samples/client/petstore/swift6/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift b/samples/client/petstore/swift6/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift index 7d60ea96189a..ef1fab8dba17 100644 --- a/samples/client/petstore/swift6/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift +++ b/samples/client/petstore/swift6/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift @@ -24,7 +24,9 @@ public enum Fruit: Sendable, Codable, ParameterConvertible, Hashable { } } + public init(from decoder: Decoder) throws { + // No discriminator: try each type sequentially let container = try decoder.singleValueContainer() if let value = try? container.decode(Apple.self) { self = .typeApple(value) From 4165a5d066111c7e81e43aa658e7230dc1360011 Mon Sep 17 00:00:00 2001 From: Jason Ak Date: Thu, 8 Jan 2026 17:59:37 +0100 Subject: [PATCH 2/4] Remove comments from modelOneOf templates --- .../src/main/resources/swift5/modelOneOf.mustache | 2 -- .../src/main/resources/swift6/modelOneOf.mustache | 2 -- .../oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift | 1 - .../oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift | 1 - 4 files changed, 6 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/swift5/modelOneOf.mustache b/modules/openapi-generator/src/main/resources/swift5/modelOneOf.mustache index bb3f1d7d4756..4bf1ce40276d 100644 --- a/modules/openapi-generator/src/main/resources/swift5/modelOneOf.mustache +++ b/modules/openapi-generator/src/main/resources/swift5/modelOneOf.mustache @@ -28,7 +28,6 @@ public init(from decoder: Decoder) throws { {{#discriminator}} - // Discriminator-based decoding: read discriminator value first, then decode the correct variant let keyedContainer = try decoder.container(keyedBy: DiscriminatorCodingKey.self) let discriminatorValue = try keyedContainer.decode(String.self, forKey: .{{discriminator.propertyName}}) @@ -52,7 +51,6 @@ } {{/discriminator}} {{^discriminator}} - // No discriminator: try each type sequentially let container = try decoder.singleValueContainer() {{#oneOf}} {{#-first}} diff --git a/modules/openapi-generator/src/main/resources/swift6/modelOneOf.mustache b/modules/openapi-generator/src/main/resources/swift6/modelOneOf.mustache index 9116be67f928..c49411f3309d 100644 --- a/modules/openapi-generator/src/main/resources/swift6/modelOneOf.mustache +++ b/modules/openapi-generator/src/main/resources/swift6/modelOneOf.mustache @@ -28,7 +28,6 @@ public init(from decoder: Decoder) throws { {{#discriminator}} - // Discriminator-based decoding: read discriminator value first, then decode the correct variant let keyedContainer = try decoder.container(keyedBy: DiscriminatorCodingKey.self) let discriminatorValue = try keyedContainer.decode(String.self, forKey: .{{discriminator.propertyName}}) @@ -52,7 +51,6 @@ } {{/discriminator}} {{^discriminator}} - // No discriminator: try each type sequentially let container = try decoder.singleValueContainer() {{#oneOf}} {{#-first}} diff --git a/samples/client/petstore/swift5/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift b/samples/client/petstore/swift5/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift index 89abe8b3d6cd..ca95d10ffb06 100644 --- a/samples/client/petstore/swift5/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift +++ b/samples/client/petstore/swift5/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift @@ -29,7 +29,6 @@ public enum Fruit: Codable, JSONEncodable, Hashable { public init(from decoder: Decoder) throws { - // No discriminator: try each type sequentially let container = try decoder.singleValueContainer() if let value = try? container.decode(Apple.self) { self = .typeApple(value) diff --git a/samples/client/petstore/swift6/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift b/samples/client/petstore/swift6/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift index ef1fab8dba17..20c9683e14fb 100644 --- a/samples/client/petstore/swift6/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift +++ b/samples/client/petstore/swift6/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift @@ -26,7 +26,6 @@ public enum Fruit: Sendable, Codable, ParameterConvertible, Hashable { public init(from decoder: Decoder) throws { - // No discriminator: try each type sequentially let container = try decoder.singleValueContainer() if let value = try? container.decode(Apple.self) { self = .typeApple(value) From 96b6a889bf0045fb7dca115d129f20a848d7768a Mon Sep 17 00:00:00 2001 From: Jason Ak Date: Thu, 8 Jan 2026 18:26:35 +0100 Subject: [PATCH 3/4] Add tests for oneOf discriminator-first decoding - Add 1 integration test for Swift5 generator - Add 1 integration test for Swift6 generator - Tests validate generated code uses discriminator-first decoding pattern - Tests verify switch on discriminator value instead of sequential try? --- .../swift5/Swift5ClientCodegenTest.java | 38 +++++++++++++++++++ .../swift6/Swift6ClientCodegenTest.java | 38 +++++++++++++++++++ 2 files changed, 76 insertions(+) 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 8ce28b3467e4..712b86e1e2e9 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 @@ -317,4 +317,42 @@ public void oneOfFormParameterTest() { } + @Test(description = "test oneOf with discriminator generates discriminator-first decoding", enabled = true) + public void oneOfDiscriminatorFirstDecodingTest() throws IOException { + Path target = Files.createTempDirectory("test"); + File output = target.toFile(); + try { + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("swift5") + .setInputSpec("src/test/resources/3_0/oneOfDiscriminator.yaml") + .setOutputDir(target.toAbsolutePath().toString()); + + final ClientOptInput clientOptInput = configurator.toClientOptInput(); + DefaultGenerator generator = new DefaultGenerator(false); + generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); + + List files = generator.opts(clientOptInput).generate(); + + File modelFile = files.stream() + .filter(f -> f.getName().equals("FruitOneOfEnumMappingDisc.swift")) + .findFirst() + .orElseThrow(() -> new RuntimeException("FruitOneOfEnumMappingDisc.swift not found")); + + String content = Files.readString(modelFile.toPath()); + + // Verify discriminator-first decoding pattern + Assert.assertTrue(content.contains("private enum DiscriminatorCodingKey: String, CodingKey")); + Assert.assertTrue(content.contains("let keyedContainer = try decoder.container(keyedBy: DiscriminatorCodingKey.self)")); + Assert.assertTrue(content.contains("switch discriminatorValue")); + Assert.assertTrue(content.contains("case \"APPLE\":")); + Assert.assertTrue(content.contains("self = .typeAppleOneOfEnumMappingDisc(try AppleOneOfEnumMappingDisc(from: decoder))")); + Assert.assertFalse(content.contains("if let value = try? container.decode(AppleOneOfEnumMappingDisc.self)")); + + } finally { + output.deleteOnExit(); + } + } + } 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 4fcfe0d9728f..1bba4234b30f 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 @@ -363,4 +363,42 @@ public void oneOfArrayTypeNamesTest() throws IOException { output.deleteOnExit(); } } + + @Test(description = "test oneOf with discriminator generates discriminator-first decoding", enabled = true) + public void oneOfDiscriminatorFirstDecodingTest() throws IOException { + Path target = Files.createTempDirectory("test"); + File output = target.toFile(); + try { + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("swift6") + .setInputSpec("src/test/resources/3_0/oneOfDiscriminator.yaml") + .setOutputDir(target.toAbsolutePath().toString()); + + final ClientOptInput clientOptInput = configurator.toClientOptInput(); + DefaultGenerator generator = new DefaultGenerator(false); + generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); + + List files = generator.opts(clientOptInput).generate(); + + File modelFile = files.stream() + .filter(f -> f.getName().equals("FruitOneOfEnumMappingDisc.swift")) + .findFirst() + .orElseThrow(() -> new RuntimeException("FruitOneOfEnumMappingDisc.swift not found")); + + String content = Files.readString(modelFile.toPath()); + + // Verify discriminator-first decoding pattern + Assert.assertTrue(content.contains("private enum DiscriminatorCodingKey: String, CodingKey")); + Assert.assertTrue(content.contains("let keyedContainer = try decoder.container(keyedBy: DiscriminatorCodingKey.self)")); + Assert.assertTrue(content.contains("switch discriminatorValue")); + Assert.assertTrue(content.contains("case \"APPLE\":")); + Assert.assertTrue(content.contains("self = .typeAppleOneOfEnumMappingDisc(try AppleOneOfEnumMappingDisc(from: decoder))")); + Assert.assertFalse(content.contains("if let value = try? container.decode(AppleOneOfEnumMappingDisc.self)")); + + } finally { + output.deleteOnExit(); + } + } } From 74aa7438c111acafc9a45959e1e3cb163f5d8218 Mon Sep 17 00:00:00 2001 From: Jason Ak Date: Mon, 12 Jan 2026 11:53:05 +0100 Subject: [PATCH 4/4] Fix whitespace in Swift oneOf templates Remove extra blank line before public init(from decoder:) in generated oneOf Swift files by adjusting mustache tag placement. --- .../src/main/resources/swift5/modelOneOf.mustache | 14 +++++--------- .../src/main/resources/swift6/modelOneOf.mustache | 14 +++++--------- .../Classes/OpenAPIs/Models/Fruit.swift | 1 - .../Classes/OpenAPIs/Models/Fruit.swift | 1 - 4 files changed, 10 insertions(+), 20 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/swift5/modelOneOf.mustache b/modules/openapi-generator/src/main/resources/swift5/modelOneOf.mustache index 4bf1ce40276d..ec8c1bde0459 100644 --- a/modules/openapi-generator/src/main/resources/swift5/modelOneOf.mustache +++ b/modules/openapi-generator/src/main/resources/swift5/modelOneOf.mustache @@ -19,16 +19,15 @@ {{/oneOfUnknownDefaultCase}} } } +{{#discriminator}} - {{#discriminator}} private enum DiscriminatorCodingKey: String, CodingKey { case {{discriminator.propertyName}} = "{{discriminator.propertyBaseName}}" } - {{/discriminator}} +{{/discriminator}} public init(from decoder: Decoder) throws { - {{#discriminator}} - let keyedContainer = try decoder.container(keyedBy: DiscriminatorCodingKey.self) +{{#discriminator}} let keyedContainer = try decoder.container(keyedBy: DiscriminatorCodingKey.self) let discriminatorValue = try keyedContainer.decode(String.self, forKey: .{{discriminator.propertyName}}) switch discriminatorValue { @@ -49,9 +48,7 @@ ) {{/oneOfUnknownDefaultCase}} } - {{/discriminator}} - {{^discriminator}} - let container = try decoder.singleValueContainer() +{{/discriminator}}{{^discriminator}} let container = try decoder.singleValueContainer() {{#oneOf}} {{#-first}} if let value = try? container.decode({{.}}.self) { @@ -69,6 +66,5 @@ throw DecodingError.typeMismatch(Self.Type.self, .init(codingPath: decoder.codingPath, debugDescription: "Unable to decode instance of {{classname}}")) {{/oneOfUnknownDefaultCase}} } - {{/discriminator}} - } +{{/discriminator}} } } diff --git a/modules/openapi-generator/src/main/resources/swift6/modelOneOf.mustache b/modules/openapi-generator/src/main/resources/swift6/modelOneOf.mustache index c49411f3309d..abcf9782c7fc 100644 --- a/modules/openapi-generator/src/main/resources/swift6/modelOneOf.mustache +++ b/modules/openapi-generator/src/main/resources/swift6/modelOneOf.mustache @@ -19,16 +19,15 @@ {{/oneOfUnknownDefaultCase}} } } +{{#discriminator}} - {{#discriminator}} private enum DiscriminatorCodingKey: String, CodingKey { case {{discriminator.propertyName}} = "{{discriminator.propertyBaseName}}" } - {{/discriminator}} +{{/discriminator}} public init(from decoder: Decoder) throws { - {{#discriminator}} - let keyedContainer = try decoder.container(keyedBy: DiscriminatorCodingKey.self) +{{#discriminator}} let keyedContainer = try decoder.container(keyedBy: DiscriminatorCodingKey.self) let discriminatorValue = try keyedContainer.decode(String.self, forKey: .{{discriminator.propertyName}}) switch discriminatorValue { @@ -49,9 +48,7 @@ ) {{/oneOfUnknownDefaultCase}} } - {{/discriminator}} - {{^discriminator}} - let container = try decoder.singleValueContainer() +{{/discriminator}}{{^discriminator}} let container = try decoder.singleValueContainer() {{#oneOf}} {{#-first}} if let value = try? container.decode({{.}}.self) { @@ -69,6 +66,5 @@ throw DecodingError.typeMismatch(Self.Type.self, .init(codingPath: decoder.codingPath, debugDescription: "Unable to decode instance of {{classname}}")) {{/oneOfUnknownDefaultCase}} } - {{/discriminator}} - } +{{/discriminator}} } } diff --git a/samples/client/petstore/swift5/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift b/samples/client/petstore/swift5/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift index ca95d10ffb06..0a7c58c949d8 100644 --- a/samples/client/petstore/swift5/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift +++ b/samples/client/petstore/swift5/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift @@ -27,7 +27,6 @@ public enum Fruit: Codable, JSONEncodable, Hashable { } } - public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let value = try? container.decode(Apple.self) { diff --git a/samples/client/petstore/swift6/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift b/samples/client/petstore/swift6/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift index 20c9683e14fb..7d60ea96189a 100644 --- a/samples/client/petstore/swift6/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift +++ b/samples/client/petstore/swift6/oneOf/PetstoreClient/Classes/OpenAPIs/Models/Fruit.swift @@ -24,7 +24,6 @@ public enum Fruit: Sendable, Codable, ParameterConvertible, Hashable { } } - public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let value = try? container.decode(Apple.self) {