Skip to content
Draft
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
68 changes: 54 additions & 14 deletions src/OpenApiCodeGenerator/CSharpCodeEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -839,23 +839,24 @@ private void EmitRecord(string schemaName, IOpenApiSchema schema, string? typeNa
{
string typeName = typeNameOverride ?? NameHelper.ToTypeName(schemaName, _options.ModelPrefix);

// Collect all properties (including from allOf)
Dictionary<string, IOpenApiSchema> properties = CollectProperties(schema);
HashSet<string> requiredProps = CollectRequired(schema);

// Determine base type from allOf $ref
// Determine base type from allOf $ref (first $ref becomes the base type)
string? baseType = null;
HashSet<string>? basePropertyNames = null;
OpenApiSchemaReference? baseRefSchema = null;
if (schema.AllOf is { Count: > 0 })
{
OpenApiSchemaReference? refSchema = schema.AllOf.OfType<OpenApiSchemaReference>().FirstOrDefault();
if (refSchema?.Reference?.Id != null)
baseRefSchema = schema.AllOf.OfType<OpenApiSchemaReference>().FirstOrDefault();
if (baseRefSchema?.Reference?.Id != null)
{
baseType = NameHelper.ToTypeName(refSchema.Reference.Id, _options.ModelPrefix);
basePropertyNames = CollectBasePropertyNames(refSchema);
baseType = NameHelper.ToTypeName(baseRefSchema.Reference.Id, _options.ModelPrefix);
basePropertyNames = CollectBasePropertyNames(baseRefSchema);
}
}

// Collect all properties (including from allOf, resolving additional $ref members)
Dictionary<string, IOpenApiSchema> properties = CollectProperties(schema, baseRefSchema);
HashSet<string> requiredProps = CollectRequired(schema, baseRefSchema);

EmitDocComment(schema.Description);

string declaration = baseType != null
Expand Down Expand Up @@ -1314,18 +1315,36 @@ private static Dictionary<string, string> ResolvePropertyNameCollisions(IEnumera

#region Helpers

private static Dictionary<string, IOpenApiSchema> CollectProperties(IOpenApiSchema schema)
private Dictionary<string, IOpenApiSchema> CollectProperties(IOpenApiSchema schema, OpenApiSchemaReference? baseRefSchema = null)
{
var result = new Dictionary<string, IOpenApiSchema>();

// Properties from allOf subschemas (excluding $ref ones which become base types)
// Properties from allOf subschemas
if (schema.AllOf is { Count: > 0 })
{
foreach (IOpenApiSchema sub in schema.AllOf)
{
if (sub is OpenApiSchemaReference)
if (sub is OpenApiSchemaReference refSub)
{
continue; // Skip $ref entries, they become base types
// Skip the base type $ref — its properties are inherited
if (baseRefSchema != null && ReferenceEquals(sub, baseRefSchema))
{
continue;
}

// Additional $ref members: resolve and include their properties
// (C# doesn't support multiple inheritance, so we flatten them)
if (refSub.Reference?.Id != null &&
_allSchemas.TryGetValue(refSub.Reference.Id, out IOpenApiSchema? resolvedSchema))
{
Dictionary<string, IOpenApiSchema> refProps = CollectProperties(resolvedSchema);
foreach ((string? name, IOpenApiSchema? prop) in refProps)
{
result.TryAdd(name, prop);
}
}

continue;
}

if (sub.Properties != null)
Expand Down Expand Up @@ -1403,7 +1422,7 @@ private HashSet<string> CollectBasePropertyNames(IOpenApiSchema baseSchema)
return names;
}

private static HashSet<string> CollectRequired(IOpenApiSchema schema)
private HashSet<string> CollectRequired(IOpenApiSchema schema, OpenApiSchemaReference? baseRefSchema = null)
{
var result = new HashSet<string>(StringComparer.Ordinal);

Expand All @@ -1420,6 +1439,27 @@ private static HashSet<string> CollectRequired(IOpenApiSchema schema)
{
foreach (IOpenApiSchema sub in schema.AllOf)
{
if (sub is OpenApiSchemaReference refSub)
{
// Skip the base type $ref — its required fields are inherited
if (baseRefSchema != null && ReferenceEquals(sub, baseRefSchema))
{
continue;
}

// Additional $ref members: resolve and collect their required fields
if (refSub.Reference?.Id != null &&
_allSchemas.TryGetValue(refSub.Reference.Id, out IOpenApiSchema? resolvedSchema))
{
foreach (string r in CollectRequired(resolvedSchema))
{
result.Add(r);
}
}

continue;
}

if (sub.Required != null)
{
foreach (string r in sub.Required)
Expand Down
182 changes: 182 additions & 0 deletions tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,188 @@

#endregion

#region allOf with Multiple $refs

[Fact]
public void Emit_AllOfMultipleRefs_FlattensAdditionalRefProperties()
{
var schemas = new Dictionary<string, IOpenApiSchema>
{
["Base"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["id"] = new OpenApiSchema { Type = JsonSchemaType.String }
}
},
["Mixin"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["label"] = new OpenApiSchema { Type = JsonSchemaType.String }
}
},
["Derived"] = new OpenApiSchema
{
AllOf = new List<IOpenApiSchema>
{
new OpenApiSchemaReference("Base"),
new OpenApiSchemaReference("Mixin"),
new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["extra"] = new OpenApiSchema { Type = JsonSchemaType.String }
}
}
}
}
};

string result = Generate(schemas);

// First $ref becomes the base type
Assert.Contains("public partial record Derived : Base", result, StringComparison.Ordinal);

// Properties from the second $ref (Mixin) should be flattened into Derived
Assert.Contains("public string? Label { get; init; }", result, StringComparison.Ordinal);

// Properties from the inline allOf member should also be present
Assert.Contains("public string? Extra { get; init; }", result, StringComparison.Ordinal);

// Properties from the base type should NOT be re-declared
// (they're inherited from Base)
var derivedSection = result[result.IndexOf("record Derived", StringComparison.Ordinal)..];
Assert.DoesNotContain("public string? Id", derivedSection, StringComparison.Ordinal);
}

[Fact]
public void Emit_AllOfMultipleRefs_CollectsRequiredFromAdditionalRefs()
{
var schemas = new Dictionary<string, IOpenApiSchema>
{
["Base"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["id"] = new OpenApiSchema { Type = JsonSchemaType.String }
}
},
["Mixin"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Required = new HashSet<string> { "label" },
Properties = new Dictionary<string, IOpenApiSchema>
{
["label"] = new OpenApiSchema { Type = JsonSchemaType.String }
}
},
["Derived"] = new OpenApiSchema
{
AllOf = new List<IOpenApiSchema>
{
new OpenApiSchemaReference("Base"),
new OpenApiSchemaReference("Mixin")
}
}
};

string result = Generate(schemas);

// "label" is required in Mixin, so it should be required in Derived
Assert.Contains("public required string Label { get; init; }", result, StringComparison.Ordinal);
}

[Fact]
public async Task Emit_AllOfMultipleRefs_CompilesSuccessfully()
{
var schemas = new Dictionary<string, IOpenApiSchema>
{
["Base"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["id"] = new OpenApiSchema { Type = JsonSchemaType.String }
}
},
["Mixin"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Required = new HashSet<string> { "label" },
Properties = new Dictionary<string, IOpenApiSchema>
{
["label"] = new OpenApiSchema { Type = JsonSchemaType.String }
}
},
["Derived"] = new OpenApiSchema
{
AllOf = new List<IOpenApiSchema>
{
new OpenApiSchemaReference("Base"),
new OpenApiSchemaReference("Mixin"),
new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["extra"] = new OpenApiSchema { Type = JsonSchemaType.String }
}
}
}
}
};

string result = Generate(schemas);

// Compile in a temp project
string tempRoot = Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "..",
"TestResults", "AllOfMultiRefCompile", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
try
{
await File.WriteAllTextAsync(Path.Combine(tempRoot, "Generated.cs"), result);

Check warning on line 593 in tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 593 in tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
await File.WriteAllTextAsync(Path.Combine(tempRoot, "Harness.csproj"), """

Check warning on line 594 in tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 594 in tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<AnalysisMode>All</AnalysisMode>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
""");
using var proc = new System.Diagnostics.Process();
proc.StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "dotnet",
Arguments = $"build \"{Path.Combine(tempRoot, "Harness.csproj")}\" -v q --nologo",
WorkingDirectory = tempRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
proc.Start();
string stdout = await proc.StandardOutput.ReadToEndAsync();

Check warning on line 616 in tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 616 in tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
string stderr = await proc.StandardError.ReadToEndAsync();

Check warning on line 617 in tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 617 in tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
await proc.WaitForExitAsync();

Check warning on line 618 in tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)

Check warning on line 618 in tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs

View workflow job for this annotation

GitHub Actions / build

Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken to allow test cancellation to be more responsive. (https://xunit.net/xunit.analyzers/rules/xUnit1051)
Assert.True(proc.ExitCode == 0,
$"allOf multi-ref code failed to compile.{Environment.NewLine}STDOUT:{stdout}{Environment.NewLine}STDERR:{stderr}");
}
finally
{
if (Directory.Exists(tempRoot)) Directory.Delete(tempRoot, recursive: true);
}
}

#endregion

#region Union Types (oneOf)

[Fact]
Expand Down