Skip to content
Merged
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
43 changes: 43 additions & 0 deletions src/Refitter.Core/CSharpClientGeneratorFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public CustomCSharpClientGenerator Create()
}
}

ConvertOneOfWithDiscriminatorToAllOf();
FixMissingTypesWithIntegerFormat();
ApplyCustomIntegerType();

Expand Down Expand Up @@ -65,6 +66,48 @@ public CustomCSharpClientGenerator Create()
return generator;
}

/// <summary>
/// Converts schemas that use oneOf/anyOf with a discriminator to use the allOf inheritance
/// pattern that NSwag's C# code generator understands. Without this transformation,
/// NSwag generates undefined anonymous types (e.g., "IdentityProvider2") instead of
/// proper base class references.
/// </summary>
private void ConvertOneOfWithDiscriminatorToAllOf()
{
foreach (var kvp in document.Components.Schemas)
{
var schema = kvp.Value.ActualSchema;

if (schema.DiscriminatorObject == null)
continue;

var unionSchemas = schema.OneOf.Concat(schema.AnyOf).ToArray();
if (unionSchemas.Length == 0)
continue;

// Ensure the base schema is typed as an object
if (schema.Type == JsonObjectType.None || schema.Type == JsonObjectType.Null)
schema.Type = JsonObjectType.Object;

// For each subtype, add allOf pointing to the base schema if not already present
foreach (var subSchemaRef in unionSchemas)
{
var subSchema = subSchemaRef.ActualSchema;
bool alreadyInherits = subSchema.AllOf.Any(
a => a.HasReference && a.ActualSchema == schema);
if (!alreadyInherits)
{
var reference = new JsonSchema { Reference = schema };
subSchema.AllOf.Add(reference);
}
}

// Remove the oneOf/anyOf from the base schema now that subtypes use allOf
schema.OneOf.Clear();
schema.AnyOf.Clear();
}
}

private void FixMissingTypesWithIntegerFormat()
{
ProcessSchemasForMissingTypes(document.Components.Schemas);
Expand Down
283 changes: 283 additions & 0 deletions src/Refitter.Tests/Examples/OneOfDiscriminatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
using FluentAssertions;
using Refitter.Core;
using Refitter.Tests.Build;
using Refitter.Tests.TestUtilities;
using TUnit.Core;

namespace Refitter.Tests.Examples;

public class OneOfDiscriminatorTests
{
private const string OpenApiSpec = @"
openapi: 3.0.1
info:
title: Test
version: v1
paths:
/api/identityproviders:
get:
operationId: GetIdentityProvider
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/IdentityProviderResponse'
components:
schemas:
IdentityProviderResponse:
type: object
properties:
identityProvider:
$ref: '#/components/schemas/IdentityProvider'
identityProviders:
type: array
items:
$ref: '#/components/schemas/IdentityProvider'
IdentityProvider:
oneOf:
- $ref: '#/components/schemas/AppleIdentityProvider'
- $ref: '#/components/schemas/FacebookIdentityProvider'
discriminator:
propertyName: type
mapping:
Apple: '#/components/schemas/AppleIdentityProvider'
Facebook: '#/components/schemas/FacebookIdentityProvider'
AppleIdentityProvider:
type: object
properties:
type:
type: string
bundleId:
type: string
FacebookIdentityProvider:
type: object
properties:
type:
type: string
appId:
type: string
";

private const string AnyOfOpenApiSpec = @"
openapi: 3.0.1
info:
title: Test
version: v1
paths:
/api/notifications:
get:
operationId: GetNotification
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/NotificationResponse'
components:
schemas:
NotificationResponse:
type: object
properties:
notification:
$ref: '#/components/schemas/Notification'
notifications:
type: array
items:
$ref: '#/components/schemas/Notification'
Notification:
anyOf:
- $ref: '#/components/schemas/EmailNotification'
- $ref: '#/components/schemas/SmsNotification'
discriminator:
propertyName: channel
mapping:
Email: '#/components/schemas/EmailNotification'
Sms: '#/components/schemas/SmsNotification'
EmailNotification:
type: object
properties:
channel:
type: string
emailAddress:
type: string
SmsNotification:
type: object
properties:
channel:
type: string
phoneNumber:
type: string
";

[Test]

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

The ConvertOneOfWithDiscriminatorToAllOf method handles anyOf + discriminator (line 84 concatenates schema.AnyOf), but there is no test case for the anyOf with discriminator pattern. Given the test suite has comprehensive tests for oneOf + discriminator, the analogous anyOf path in the implementation is untested. If NSwag handles anyOf differently from oneOf during code generation, this path could silently produce incorrect output.

Copilot uses AI. Check for mistakes.
public async Task Can_Generate_Code()
{
string generatedCode = await GenerateCode();
generatedCode.Should().NotBeNullOrWhiteSpace();
}

[Test]
public async Task Generates_Base_Type_For_OneOf_With_Discriminator()
{
string generatedCode = await GenerateCode();
generatedCode.Should().Contain("class IdentityProvider");

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

The assertion generatedCode.Should().Contain("class IdentityProvider") at line 126 is a weak test because it can match class IdentityProviderResponse (which is always generated regardless of the fix). If only IdentityProviderResponse were present and IdentityProvider were missing, this test would still pass. A more precise assertion would use "class IdentityProvider\r", "class IdentityProvider\n", or a pattern that does not match the Response suffix, such as "\npartial class IdentityProvider" or a whitespace-terminated check. The same issue applies to line 207 ("class Notification") which could match the class NotificationResponse.

Suggested change
generatedCode.Should().Contain("class IdentityProvider");
generatedCode.Should().Contain("class IdentityProvider ");

Copilot uses AI. Check for mistakes.
}

[Test]
public async Task Does_Not_Generate_Anonymous_Type_For_OneOf_With_Discriminator()
{
string generatedCode = await GenerateCode();
generatedCode.Should().NotContain("IdentityProvider2");
}

[Test]
public async Task Generates_All_Subtypes_For_OneOf_With_Discriminator()
{
string generatedCode = await GenerateCode();
generatedCode.Should().Contain("class AppleIdentityProvider");
generatedCode.Should().Contain("class FacebookIdentityProvider");
}

[Test]
public async Task Generates_Inheritance_Hierarchy_For_OneOf_With_Discriminator()
{
string generatedCode = await GenerateCode();
generatedCode.Should().Contain("AppleIdentityProvider : IdentityProvider");
generatedCode.Should().Contain("FacebookIdentityProvider : IdentityProvider");
}

[Test]
public async Task Generates_Correct_Property_Type_For_OneOf_With_Discriminator()
{
string generatedCode = await GenerateCode();
generatedCode.Should().Contain("IdentityProvider IdentityProvider");
generatedCode.Should().Contain("ICollection<IdentityProvider> IdentityProviders");
}

[Test]
public async Task Can_Build_Generated_Code()
{
string generatedCode = await GenerateCode();
BuildHelper
.BuildCSharp(generatedCode)
.Should()
.BeTrue();
}

[Test]
public async Task Can_Generate_Code_With_Polymorphic_Serialization()
{
string generatedCode = await GenerateCode(usePolymorphicSerialization: true);
generatedCode.Should().NotBeNullOrWhiteSpace();
}

[Test]
public async Task Generates_JsonPolymorphic_Attributes_For_OneOf_With_Discriminator()
{
string generatedCode = await GenerateCode(usePolymorphicSerialization: true);
generatedCode.Should().Contain("[JsonPolymorphic(TypeDiscriminatorPropertyName = \"type\"");
generatedCode.Should().Contain("[JsonDerivedType(typeof(AppleIdentityProvider)");
generatedCode.Should().Contain("[JsonDerivedType(typeof(FacebookIdentityProvider)");
}

[Test]
public async Task Can_Build_Generated_Code_With_Polymorphic_Serialization()
{
string generatedCode = await GenerateCode(usePolymorphicSerialization: true);
BuildHelper
.BuildCSharp(generatedCode)
.Should()
.BeTrue();
}

[Test]
public async Task Can_Generate_Code_For_AnyOf_With_Discriminator()
{
string generatedCode = await GenerateCode(spec: AnyOfOpenApiSpec);
generatedCode.Should().NotBeNullOrWhiteSpace();
}

[Test]
public async Task Generates_Base_Type_For_AnyOf_With_Discriminator()
{
string generatedCode = await GenerateCode(spec: AnyOfOpenApiSpec);
generatedCode.Should().Contain("class Notification");

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

The assertion generatedCode.Should().Contain("class Notification") at line 207 is a weak test because it will also match class NotificationResponse (which is always generated). A more precise assertion should distinguish the base type Notification from the response wrapper, e.g. checking for "class Notification\n" or a partial class declaration pattern.

Suggested change
generatedCode.Should().Contain("class Notification");
generatedCode.Should().Contain("class Notification\n");

Copilot uses AI. Check for mistakes.
}

[Test]
public async Task Does_Not_Generate_Anonymous_Type_For_AnyOf_With_Discriminator()
{
string generatedCode = await GenerateCode(spec: AnyOfOpenApiSpec);
generatedCode.Should().NotContain("Notification2");
}

[Test]
public async Task Generates_All_Subtypes_For_AnyOf_With_Discriminator()
{
string generatedCode = await GenerateCode(spec: AnyOfOpenApiSpec);
generatedCode.Should().Contain("class EmailNotification");
generatedCode.Should().Contain("class SmsNotification");
}

[Test]
public async Task Generates_Inheritance_Hierarchy_For_AnyOf_With_Discriminator()
{
string generatedCode = await GenerateCode(spec: AnyOfOpenApiSpec);
generatedCode.Should().Contain("EmailNotification : Notification");
generatedCode.Should().Contain("SmsNotification : Notification");
}

[Test]
public async Task Generates_Correct_Property_Type_For_AnyOf_With_Discriminator()
{
string generatedCode = await GenerateCode(spec: AnyOfOpenApiSpec);
generatedCode.Should().Contain("Notification Notification");
generatedCode.Should().Contain("ICollection<Notification> Notifications");
}

[Test]
public async Task Can_Build_Generated_Code_For_AnyOf_With_Discriminator()
{
string generatedCode = await GenerateCode(spec: AnyOfOpenApiSpec);
BuildHelper
.BuildCSharp(generatedCode)
.Should()
.BeTrue();
}

[Test]
public async Task Generates_JsonPolymorphic_Attributes_For_AnyOf_With_Discriminator()
{
string generatedCode = await GenerateCode(spec: AnyOfOpenApiSpec, usePolymorphicSerialization: true);
generatedCode.Should().Contain("[JsonPolymorphic(TypeDiscriminatorPropertyName = \"channel\"");
generatedCode.Should().Contain("[JsonDerivedType(typeof(EmailNotification)");
generatedCode.Should().Contain("[JsonDerivedType(typeof(SmsNotification)");
}

[Test]
public async Task Can_Build_Generated_Code_For_AnyOf_With_Discriminator_And_Polymorphic_Serialization()
{
string generatedCode = await GenerateCode(spec: AnyOfOpenApiSpec, usePolymorphicSerialization: true);
BuildHelper
.BuildCSharp(generatedCode)
.Should()
.BeTrue();
}

private static async Task<string> GenerateCode(string spec = OpenApiSpec, bool usePolymorphicSerialization = false)
{
var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(spec);
var settings = new RefitGeneratorSettings
{
OpenApiPath = swaggerFile,
UsePolymorphicSerialization = usePolymorphicSerialization,
};

var sut = await RefitGenerator.CreateAsync(settings);
var generatedCode = sut.Generate();
return generatedCode;
}
Comment on lines +270 to +282

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

There is no test coverage for the combination of TrimUnusedSchema = true with oneOf/anyOf + discriminator. The SchemaCleaner runs before ConvertOneOfWithDiscriminatorToAllOf() (in RefitGenerator.CreateAsync vs CSharpClientGeneratorFactory.Create()), which means subtypes are preserved by the cleaner (since it enumerates OneOf/AnyOf entries unconditionally), and the transformation runs afterwards. However, this interaction is not tested. Adding a test with TrimUnusedSchema = true would improve confidence that these two features compose correctly.

Copilot uses AI. Check for mistakes.
}
Loading