Repository navigation
fix(csharp): serialize object oneOf variants #25549
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| /* | ||
| * Copyright 2026 OpenAPI-Generator Contributors (https://openapi-generator.tech) | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
| */ | ||
| package org.openapitools.codegen.csharpnetcore; | ||
|
|
||
| import org.openapitools.codegen.DefaultGenerator; | ||
| import org.openapitools.codegen.config.CodegenConfigurator; | ||
| import org.testng.annotations.Test; | ||
|
|
||
| import java.nio.file.Path; | ||
|
|
||
| import static org.openapitools.codegen.TestUtils.assertFileContains; | ||
| import static org.openapitools.codegen.TestUtils.newTempFolder; | ||
|
|
||
| public class CSharpGenericHostOneOfSerializationTest { | ||
| @Test | ||
| public void testObjectOneOfWithoutDiscriminatorWritesVariant() throws Exception { | ||
| Path output = newTempFolder(); | ||
| CodegenConfigurator config = new CodegenConfigurator().setGeneratorName("csharp").setLibrary("generichost") | ||
| .setInputSpec("src/test/resources/3_0/csharp/oneof-object-serialization.yaml") | ||
| .setOutputDir(output.toString()).addAdditionalProperty("packageName", "OneOfRepro"); | ||
| new DefaultGenerator().opts(config.toClientOptInput()).generate(); | ||
| assertFileContains(output.resolve("src/OneOfRepro/Model/Entry.cs"), | ||
| "if (entry.FileEntry != null)", | ||
| "JsonSerializer.Serialize(variant, variant.GetType(), variantOptions)", | ||
| "if (entry.FolderEntry != null)", | ||
| "Multiple oneOf variants are set for Entry."); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| openapi: 3.0.3 | ||
| info: | ||
| title: oneOf reproduction | ||
| version: 1.0.0 | ||
| paths: {} | ||
| components: | ||
| schemas: | ||
| Entry: | ||
| oneOf: | ||
| - $ref: '#/components/schemas/FileEntry' | ||
| - $ref: '#/components/schemas/FolderEntry' | ||
| FileEntry: | ||
| type: object | ||
| required: [name, size] | ||
| properties: | ||
| name: | ||
| type: string | ||
| size: | ||
| type: integer | ||
| FolderEntry: | ||
| type: object | ||
| required: [name, id] | ||
| properties: | ||
| name: | ||
| type: string | ||
| id: | ||
| type: string |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| using System; | ||
| using System.Text.Json; | ||
| using System.Text.Json.Serialization; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using Org.OpenAPITools.Client; | ||
| using Org.OpenAPITools.Model; | ||
| using Xunit; | ||
|
|
||
| namespace Org.OpenAPITools.Test.Model | ||
| { | ||
| public class OneOfSerializationRegressionTests | ||
| { | ||
| [Fact] | ||
| public void WritesActiveObjectVariantAndWrapperProperties() | ||
| { | ||
| JsonSerializerOptions? options = null; | ||
| new HostConfiguration(new ServiceCollection()).ConfigureJsonOptions(value => options = value); | ||
| Assert.NotNull(options); | ||
| Fruit fruit = JsonSerializer.Deserialize<Fruit>("{\"kind\":\"gala\",\"color\":\"red\"}", options)!; | ||
| Assert.NotNull(fruit.Apple); | ||
| using (JsonDocument json = JsonDocument.Parse(JsonSerializer.Serialize(fruit, options))) | ||
| { | ||
| Assert.Equal("gala", json.RootElement.GetProperty("kind").GetString()); | ||
| Assert.Equal("red", json.RootElement.GetProperty("color").GetString()); | ||
| } | ||
|
|
||
| fruit.Apple = null; | ||
| fruit.Banana = new Banana { Count = 2 }; | ||
| using (JsonDocument json = JsonDocument.Parse(JsonSerializer.Serialize(fruit, options))) | ||
| { | ||
| Assert.Equal(2, json.RootElement.GetProperty("count").GetInt32()); | ||
| Assert.Equal("red", json.RootElement.GetProperty("color").GetString()); | ||
| } | ||
| } | ||
|
|
||
| private static Fruit CreateFruit() | ||
| { | ||
| JsonSerializerOptions? options = null; | ||
| new HostConfiguration(new ServiceCollection()).ConfigureJsonOptions(value => options = value); | ||
| return JsonSerializer.Deserialize<Fruit>("{\"kind\":\"gala\"}", options!)!; | ||
| } | ||
|
|
||
| [Fact] | ||
| public void RejectsMultipleVariants() | ||
| { | ||
| Fruit fruit = CreateFruit(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The custom- and derived-variant tests do not verify that wrapper properties remain serialized. Set Prompt for AI agents |
||
| fruit.Banana = new Banana { Count = 2 }; | ||
| var options = new JsonSerializerOptions(); | ||
| options.Converters.Add(new FruitJsonConverter()); | ||
| Assert.Throws<JsonException>(() => JsonSerializer.Serialize(fruit, options)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void UsesGeneratedVariantConverterWhenNotRegistered() | ||
| { | ||
| Fruit fruit = CreateFruit(); | ||
| var options = new JsonSerializerOptions(); | ||
| options.Converters.Add(new FruitJsonConverter()); | ||
| using JsonDocument json = JsonDocument.Parse(JsonSerializer.Serialize(fruit, options)); | ||
| Assert.Equal("gala", json.RootElement.GetProperty("kind").GetString()); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void UsesCustomVariantConverter() | ||
| { | ||
| Fruit fruit = CreateFruit(); | ||
| var options = new JsonSerializerOptions(); | ||
| options.Converters.Add(new FruitJsonConverter()); | ||
| options.Converters.Add(new CustomAppleConverter()); | ||
| using JsonDocument json = JsonDocument.Parse(JsonSerializer.Serialize(fruit, options)); | ||
| Assert.Equal("custom", json.RootElement.GetProperty("kind").GetString()); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void UsesDerivedVariantConverter() | ||
| { | ||
| Fruit fruit = CreateFruit(); | ||
| fruit.Apple = new FancyApple(); | ||
| var options = new JsonSerializerOptions(); | ||
| options.Converters.Add(new FruitJsonConverter()); | ||
| options.Converters.Add(new FancyAppleConverter()); | ||
| using JsonDocument json = JsonDocument.Parse(JsonSerializer.Serialize(fruit, options)); | ||
| Assert.Equal("fancy", json.RootElement.GetProperty("extra").GetString()); | ||
| } | ||
|
|
||
| private sealed class CustomAppleConverter : JsonConverter<Apple> | ||
| { | ||
| public override Apple Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) => throw new NotSupportedException(); | ||
| public override void Write(Utf8JsonWriter writer, Apple value, JsonSerializerOptions options) | ||
| { | ||
| writer.WriteStartObject(); writer.WriteString("kind", "custom"); writer.WriteEndObject(); | ||
| } | ||
| } | ||
|
|
||
| private sealed class FancyApple : Apple { } | ||
|
|
||
| private sealed class FancyAppleConverter : JsonConverter<FancyApple> | ||
| { | ||
| public override FancyApple Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) => throw new NotSupportedException(); | ||
| public override void Write(Utf8JsonWriter writer, FancyApple value, JsonSerializerOptions options) | ||
| { | ||
| writer.WriteStartObject(); writer.WriteString("extra", "fancy"); writer.WriteEndObject(); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -245,6 +245,29 @@ public override void Write(Utf8JsonWriter writer, MixedOneOfContent mixedOneOfCo | |
| { | ||
| writer.WriteStartObject(); | ||
|
|
||
| bool oneOfValueWritten = false; | ||
| if (mixedOneOfContent.MixedSubId != null) | ||
| { | ||
| if (oneOfValueWritten) | ||
| throw new JsonException("Multiple oneOf variants are set for MixedOneOfContent."); | ||
| oneOfValueWritten = true; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||
| var variant = mixedOneOfContent.MixedSubId; | ||
| var variantOptions = jsonSerializerOptions; | ||
| if (!jsonSerializerOptions.Converters.Any(c => c.CanConvert(variant.GetType()))) | ||
| { | ||
| variantOptions = new JsonSerializerOptions(jsonSerializerOptions); | ||
| variantOptions.Converters.Add(new MixedSubIdJsonConverter()); | ||
| } | ||
| using (JsonDocument document = JsonDocument.Parse(JsonSerializer.Serialize(variant, variant.GetType(), variantOptions))) | ||
| { | ||
| if (document.RootElement.ValueKind != JsonValueKind.Object) | ||
| throw new JsonException("The oneOf variant must serialize as an object."); | ||
| foreach (JsonProperty property in document.RootElement.EnumerateObject()) | ||
| property.WriteTo(writer); | ||
| } | ||
| } | ||
|
|
||
| _ = oneOfValueWritten; | ||
| WriteProperties(writer, mixedOneOfContent, jsonSerializerOptions); | ||
| writer.WriteEndObject(); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: This assertion no longer verifies serialization in both oneOf branches. A regression that removes the
FolderEntryserialization while retaining theFileEntrycall would still pass becauseassertFileContainssearches the entire file; assert branch-specific generated snippets or the expected occurrence count instead.Prompt for AI agents