diff --git a/src/OpenApiCodeGenerator/CSharpCodeEmitter.cs b/src/OpenApiCodeGenerator/CSharpCodeEmitter.cs index 80a1d10..7a99aa8 100644 --- a/src/OpenApiCodeGenerator/CSharpCodeEmitter.cs +++ b/src/OpenApiCodeGenerator/CSharpCodeEmitter.cs @@ -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 properties = CollectProperties(schema); - HashSet 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? basePropertyNames = null; + OpenApiSchemaReference? baseRefSchema = null; if (schema.AllOf is { Count: > 0 }) { - OpenApiSchemaReference? refSchema = schema.AllOf.OfType().FirstOrDefault(); - if (refSchema?.Reference?.Id != null) + baseRefSchema = schema.AllOf.OfType().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 properties = CollectProperties(schema, baseRefSchema); + HashSet requiredProps = CollectRequired(schema, baseRefSchema); + EmitDocComment(schema.Description); string declaration = baseType != null @@ -1314,18 +1315,36 @@ private static Dictionary ResolvePropertyNameCollisions(IEnumera #region Helpers - private static Dictionary CollectProperties(IOpenApiSchema schema) + private Dictionary CollectProperties(IOpenApiSchema schema, OpenApiSchemaReference? baseRefSchema = null) { var result = new Dictionary(); - // 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 refProps = CollectProperties(resolvedSchema); + foreach ((string? name, IOpenApiSchema? prop) in refProps) + { + result.TryAdd(name, prop); + } + } + + continue; } if (sub.Properties != null) @@ -1403,7 +1422,7 @@ private HashSet CollectBasePropertyNames(IOpenApiSchema baseSchema) return names; } - private static HashSet CollectRequired(IOpenApiSchema schema) + private HashSet CollectRequired(IOpenApiSchema schema, OpenApiSchemaReference? baseRefSchema = null) { var result = new HashSet(StringComparer.Ordinal); @@ -1420,6 +1439,27 @@ private static HashSet 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) diff --git a/tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs b/tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs index 7f33c52..786a5b4 100644 --- a/tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs +++ b/tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs @@ -445,6 +445,188 @@ public void Emit_AllOfInheritance_GeneratesRecordWithBase() #endregion + #region allOf with Multiple $refs + + [Fact] + public void Emit_AllOfMultipleRefs_FlattensAdditionalRefProperties() + { + var schemas = new Dictionary + { + ["Base"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["id"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["Mixin"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["label"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["Derived"] = new OpenApiSchema + { + AllOf = new List + { + new OpenApiSchemaReference("Base"), + new OpenApiSchemaReference("Mixin"), + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["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 + { + ["Base"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["id"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["Mixin"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "label" }, + Properties = new Dictionary + { + ["label"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["Derived"] = new OpenApiSchema + { + AllOf = new List + { + 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 + { + ["Base"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["id"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["Mixin"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "label" }, + Properties = new Dictionary + { + ["label"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["Derived"] = new OpenApiSchema + { + AllOf = new List + { + new OpenApiSchemaReference("Base"), + new OpenApiSchemaReference("Mixin"), + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["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); + await File.WriteAllTextAsync(Path.Combine(tempRoot, "Harness.csproj"), """ + + + net10.0 + enable + All + true + + + """); + 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(); + string stderr = await proc.StandardError.ReadToEndAsync(); + await proc.WaitForExitAsync(); + 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]