diff --git a/src/Refitter.Core/CSharpClientGeneratorFactory.cs b/src/Refitter.Core/CSharpClientGeneratorFactory.cs index 279b6958..0955660c 100644 --- a/src/Refitter.Core/CSharpClientGeneratorFactory.cs +++ b/src/Refitter.Core/CSharpClientGeneratorFactory.cs @@ -19,6 +19,7 @@ public CustomCSharpClientGenerator Create() } } + ConvertOneOfWithDiscriminatorToAllOf(); FixMissingTypesWithIntegerFormat(); ApplyCustomIntegerType(); @@ -65,6 +66,48 @@ public CustomCSharpClientGenerator Create() return generator; } + /// + /// 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. + /// + 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); diff --git a/src/Refitter.Tests/Examples/OneOfDiscriminatorTests.cs b/src/Refitter.Tests/Examples/OneOfDiscriminatorTests.cs new file mode 100644 index 00000000..653bef3d --- /dev/null +++ b/src/Refitter.Tests/Examples/OneOfDiscriminatorTests.cs @@ -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] + 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"); + } + + [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 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"); + } + + [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 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 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; + } +}