diff --git a/examples/OpenApiCodeGenerator.Examples/README.md b/examples/OpenApiCodeGenerator.Examples/README.md index 794654c..984ad52 100644 --- a/examples/OpenApiCodeGenerator.Examples/README.md +++ b/examples/OpenApiCodeGenerator.Examples/README.md @@ -24,6 +24,7 @@ The local showcase spec is intentionally compact and demonstrates: - `allOf` inheritance - `oneOf` discriminator generation - `anyOf` union generation +- `oneOf` with inline object variants (hoisted to named records with `[JsonDerivedType]`) - string enums and inline enums - arrays, nullable fields, and `additionalProperties` - `deprecated` schemas and properties emitted with `[Obsolete]` diff --git a/examples/OpenApiCodeGenerator.Examples/output/showcase-openapi.cs b/examples/OpenApiCodeGenerator.Examples/output/showcase-openapi.cs index 99617cb..b48479c 100644 --- a/examples/OpenApiCodeGenerator.Examples/output/showcase-openapi.cs +++ b/examples/OpenApiCodeGenerator.Examples/output/showcase-openapi.cs @@ -143,20 +143,6 @@ public enum Status Failed } -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum CircleShapeType -{ - [JsonStringEnumMemberName("circle")] - Circle -} - -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum RectangleShapeType -{ - [JsonStringEnumMemberName("rectangle")] - Rectangle -} - /// /// Strongly typed UUID alias. /// @@ -297,22 +283,16 @@ public partial record DeliveryRecord : DeliveryBase } -public partial record Circle +public partial record Circle : Shape { - [JsonPropertyName("shapeType")] - public required CircleShapeType ShapeType { get; init; } - [Range(0d, 1000d)] [JsonPropertyName("radius")] public required double Radius { get; init; } } -public partial record Rectangle +public partial record Rectangle : Shape { - [JsonPropertyName("shapeType")] - public required RectangleShapeType ShapeType { get; init; } - [JsonPropertyName("width")] public required double Width { get; init; } @@ -360,6 +340,12 @@ public partial record Notification [JsonPropertyName("contact")] public required ContactMethod Contact { get; init; } + /// + /// oneOf with mixed ref and inline object variants. The inline object is hoisted to a named record and the union is emitted as an abstract record with [JsonDerivedType] attributes. + /// + [JsonPropertyName("escalation")] + public required object Escalation { get; init; } + /// /// Superseded by contact; retained for backward compatibility. /// diff --git a/examples/OpenApiCodeGenerator.Examples/specs/showcase-openapi.yaml b/examples/OpenApiCodeGenerator.Examples/specs/showcase-openapi.yaml index 3363099..4a76a6a 100644 --- a/examples/OpenApiCodeGenerator.Examples/specs/showcase-openapi.yaml +++ b/examples/OpenApiCodeGenerator.Examples/specs/showcase-openapi.yaml @@ -174,6 +174,7 @@ components: - record - preferredShape - contact + - escalation properties: record: $ref: '#/components/schemas/DeliveryRecord' @@ -181,6 +182,23 @@ components: $ref: '#/components/schemas/Shape' contact: $ref: '#/components/schemas/ContactMethod' + escalation: + description: >- + oneOf with mixed ref and inline object variants. The inline object + is hoisted to a named record and the union is emitted as an abstract + record with [JsonDerivedType] attributes. + oneOf: + - $ref: '#/components/schemas/EmailContact' + - type: object + description: Inline escalation variant hoisted to a named record. + required: + - phone + properties: + phone: + type: string + pattern: '^\+?[0-9]{6,15}$' + afterHours: + type: boolean legacyChannel: type: string deprecated: true diff --git a/src/OpenApiCodeGenerator/CSharpCodeEmitter.cs b/src/OpenApiCodeGenerator/CSharpCodeEmitter.cs index 3903097..e078201 100644 --- a/src/OpenApiCodeGenerator/CSharpCodeEmitter.cs +++ b/src/OpenApiCodeGenerator/CSharpCodeEmitter.cs @@ -18,6 +18,14 @@ internal class CSharpCodeEmitter private readonly StringBuilder _sb = new(); private int _indent; + /// + /// Maps a union variant schema → (resolved base type name, discriminator property name). + /// Populated during so that + /// can emit the correct base type for variants of discriminated unions and skip the + /// discriminator property (handled by the polymorphic serializer). + /// + private readonly Dictionary _unionVariantBaseTypes = new(ReferenceEqualityComparer.Instance); + /// /// Maps (schemaName, propertyName) → resolved enum type name for inline enums. /// Built during so that can reference @@ -304,9 +312,23 @@ private void EmitBinaryStreamTypeAliasJsonConverter() foreach ((string? schemaName, IOpenApiSchema? schema) in _allSchemas) { + // If this schema is a variant of a discriminated union, its discriminator + // property is handled by the polymorphic serializer and skipped in EmitRecord. + // Don't collect it as an inline enum — it would be emitted but never referenced. + string? skipDiscriminator = null; + if (_unionVariantBaseTypes.TryGetValue(schema, out (string BaseTypeName, string DiscriminatorPropertyName) unionInfo)) + { + skipDiscriminator = unionInfo.DiscriminatorPropertyName; + } + Dictionary properties = CollectProperties(schema); foreach ((string? propName, IOpenApiSchema? propSchema) in properties) { + if (skipDiscriminator != null && string.Equals(propName, skipDiscriminator, StringComparison.Ordinal)) + { + continue; + } + if (TypeResolver.IsEnum(propSchema) && !_knownSchemas.Contains(propSchema)) { string enumTypeName = NameHelper.ToPropertyName(propName, typeNameMap.GetValueOrDefault(schemaName)); @@ -517,6 +539,47 @@ private void DiscoverInlineObjects( { TryHoistPropertySchema(ownAddProps, enclosingTypeName, "Value", typeNameMap, usedNames, visited); } + + // Scan oneOf/anyOf variants for inline objects (handles component-level union schemas + // where variants include inline object definitions that need to be hoisted). + // Skip when the schema has its own direct properties — in that case the schema is + // emitted as a record and the oneOf/anyOf is not consumed, so hoisting would + // produce orphan types. + IList? rawUnionVariants = schema.OneOf ?? schema.AnyOf; + if (rawUnionVariants != null && properties.Count == 0) + { + // Filter null-type variants to keep variant numbering sequential. + IEnumerable unionVariants = + rawUnionVariants.Where(s => !(s.Type.HasValue && s.Type.Value == JsonSchemaType.Null)); + + bool isDiscriminated = schema.Discriminator is { PropertyName: not null }; + string? discPropName = schema.Discriminator?.PropertyName; + + int variantIndex = 1; + foreach (IOpenApiSchema variant in unionVariants) + { + if (isDiscriminated && variant is not OpenApiSchemaReference && !_knownSchemas.Contains(variant)) + { + // Only hoist inline objects for discriminated unions — non-discriminated + // unions with inline variants resolve to object (JsonElement) since + // System.Text.Json can't determine the variant without a discriminator. + string variantPropName = $"Variant{variantIndex}"; + TryHoistPropertySchema(variant, enclosingTypeName, variantPropName, typeNameMap, usedNames, visited); + _unionVariantBaseTypes.TryAdd(variant, (enclosingTypeName, discPropName!)); + } + else if (isDiscriminated && variant is OpenApiSchemaReference refVariant && refVariant.Reference?.Id != null) + { + // For $ref variants in discriminated unions, record the union base type + // on the referenced schema so it inherits from the union. + if (_allSchemas.TryGetValue(refVariant.Reference.Id, out IOpenApiSchema? refSchema)) + { + _unionVariantBaseTypes.TryAdd(refSchema, (enclosingTypeName, discPropName!)); + } + } + + variantIndex++; + } + } } /// @@ -548,9 +611,48 @@ private void DiscoverInlineObjects( return inlineTypeName; } - if (IsInlineRefUnion(propSchema)) + if (IsInlineUnion(propSchema)) { string inlineTypeName = SynthesizeInlineTypeName(enclosingTypeName, propName, usedNames); + + bool isDiscriminated = propSchema.Discriminator is { PropertyName: not null }; + string? discPropName = propSchema.Discriminator?.PropertyName; + + // Hoist any inline object variants within the union before hoisting the union itself. + // This ensures the inline objects are registered with TypeResolver and emitted as records. + // Filter out null-type variants (from nullable [type, null] patterns) to + // keep variant numbering sequential, consistent with IsInlineUnion's filtering. + IList? rawVariants = propSchema.OneOf ?? propSchema.AnyOf; + if (rawVariants != null) + { + IEnumerable variants = + rawVariants.Where(s => !(s.Type.HasValue && s.Type.Value == JsonSchemaType.Null)); + int variantIndex = 1; + foreach (IOpenApiSchema variant in variants) + { + if (IsInlineObject(variant)) + { + string variantTypeName = SynthesizeInlineTypeName(inlineTypeName, $"Variant{variantIndex}", usedNames); + HoistInlineObject(variant, variantTypeName, typeNameMap, usedNames); + DiscoverInlineObjects(variant, variantTypeName, typeNameMap, usedNames, visited); + + if (isDiscriminated) + { + _unionVariantBaseTypes.TryAdd(variant, (inlineTypeName, discPropName!)); + } + } + else if (isDiscriminated && variant is OpenApiSchemaReference refVariant && refVariant.Reference?.Id != null) + { + if (_allSchemas.TryGetValue(refVariant.Reference.Id, out IOpenApiSchema? refSchema)) + { + _unionVariantBaseTypes.TryAdd(refSchema, (inlineTypeName, discPropName!)); + } + } + + variantIndex++; + } + } + HoistInlineObject(propSchema, inlineTypeName, typeNameMap, usedNames); return inlineTypeName; } @@ -642,10 +744,15 @@ private bool IsInlineAllOfComposition(IOpenApiSchema schema) } /// - /// Detects an inline oneOf/anyOf where all variants are $refs — should be - /// hoisted as an abstract record with [JsonDerivedType] attributes. + /// Detects an inline oneOf/anyOf where all variants are $refs (or, for + /// discriminated unions, inline objects) — should be hoisted as an abstract + /// record with [JsonDerivedType] attributes. + /// Inline object variants are hoisted to named records before the union is hoisted. + /// Non-discriminated unions with inline object variants are NOT hoisted — without + /// a discriminator, System.Text.Json cannot determine which variant to deserialize, + /// so they resolve to object (JsonElement) instead. /// - private bool IsInlineRefUnion(IOpenApiSchema schema) + private bool IsInlineUnion(IOpenApiSchema schema) { // Skip $ref schemas if (schema is OpenApiSchemaReference) @@ -665,8 +772,17 @@ private bool IsInlineRefUnion(IOpenApiSchema schema) return false; } - // For anyOf, skip the nullable [type, null] pattern - if (schema.AnyOf is { } anyOf) + // Filter out null-type variants (from nullable [type, null] patterns) + if (schema.OneOf is { } oneOf) + { + var nonNull = oneOf.Where(s => + !(s.Type.HasValue && s.Type.Value == JsonSchemaType.Null)).ToList(); + if (nonNull.Count != oneOf.Count) + { + variants = nonNull; + } + } + else if (schema.AnyOf is { } anyOf) { var nonNull = anyOf.Where(s => !(s.Type.HasValue && s.Type.Value == JsonSchemaType.Null)).ToList(); @@ -682,8 +798,19 @@ private bool IsInlineRefUnion(IOpenApiSchema schema) return false; } - // All variants must be $refs - return variants.All(v => v is OpenApiSchemaReference); + bool isDiscriminated = schema.Discriminator is { PropertyName: not null }; + + // For discriminated unions, all variants must be $refs or inline objects. + // For non-discriminated unions, only $ref-only variants are hoisted — + // inline object variants can't be deserialized without a discriminator. + if (isDiscriminated) + { + return variants.All(v => v is OpenApiSchemaReference || IsInlineObject(v)); + } + else + { + return variants.All(v => v is OpenApiSchemaReference); + } } private static string SynthesizeInlineTypeName(string enclosingTypeName, string propName, HashSet usedNames) @@ -885,6 +1012,16 @@ private void EmitRecord(string schemaName, IOpenApiSchema schema, string? typeNa } } + // If no allOf base type, check if this schema is a variant of a discriminated + // union. Variants must inherit from the union base type for polymorphic + // deserialization (JsonDerivedType / JsonPolymorphic) to function. + string? unionDiscriminatorPropertyName = null; + if (baseType == null && _unionVariantBaseTypes.TryGetValue(schema, out (string BaseTypeName, string DiscriminatorPropertyName) unionInfo)) + { + baseType = unionInfo.BaseTypeName; + unionDiscriminatorPropertyName = unionInfo.DiscriminatorPropertyName; + } + EmitDocComment(schema.Description); EmitObsoleteAttribute(schema); @@ -899,6 +1036,15 @@ private void EmitRecord(string schemaName, IOpenApiSchema schema, string? typeNa .Where(p => basePropertyNames == null || !basePropertyNames.Contains(p.Key)) .ToList(); + // When inheriting from a discriminated union, skip the discriminator property + // on the derived type — it is handled by the polymorphic serializer. + if (unionDiscriminatorPropertyName != null) + { + filteredProps = filteredProps + .Where(p => !string.Equals(p.Key, unionDiscriminatorPropertyName, StringComparison.Ordinal)) + .ToList(); + } + // Two-pass property name resolution: detect collisions, assign clean names to // the most natural property name, differentiate others meaningfully. Dictionary propertyNameMap = ResolvePropertyNameCollisions(filteredProps.Select(p => p.Key), typeName); @@ -1176,13 +1322,24 @@ private void EmitDiscriminatedUnion( IList variants, OpenApiDiscriminator discriminator) { - // Build mapping: discriminator value → type name + // Build mapping: discriminator value → type name. + // Skip variants that are assigned to a different union base — C# only supports + // single inheritance, so a variant can only inherit from one union base type. var mapping = new Dictionary(); if (discriminator.Mapping is { Count: > 0 }) { foreach ((string? key, OpenApiSchemaReference? schemaRef) in discriminator.Mapping) { - mapping[key] = NameHelper.ToTypeName(schemaRef.Reference.Id, _options.ModelPrefix); + string? refId = schemaRef.Reference?.Id; + if (refId != null && + _allSchemas.TryGetValue(refId, out IOpenApiSchema? refSchema) && + _unionVariantBaseTypes.TryGetValue(refSchema, out (string BaseTypeName, string) baseInfo) && + baseInfo.BaseTypeName != typeName) + { + continue; // assigned to a different union — can't inherit from two bases + } + + mapping[key] = NameHelper.ToTypeName(refId!, _options.ModelPrefix); } } else @@ -1199,11 +1356,51 @@ private void EmitDiscriminatedUnion( continue; } + if (_allSchemas.TryGetValue(name, out IOpenApiSchema? refSchema) && + _unionVariantBaseTypes.TryGetValue(refSchema, out (string BaseTypeName, string) baseInfo) && + baseInfo.BaseTypeName != typeName) + { + continue; // assigned to a different union + } + mapping[name] = NameHelper.ToTypeName(name, _options.ModelPrefix); } } } + // Add hoisted inline object variants not covered by the discriminator mapping. + // The discriminator value is extracted from the variant's discriminator property + // (e.g., petType: "dog"). Falls back to the synthesized type name when no + // discriminator property value is found. + foreach (IOpenApiSchema variant in variants) + { + if (variant is OpenApiSchemaReference) + { + continue; + } + + string? hoistedName = _typeResolver.GetInlineObjectTypeName(variant); + if (hoistedName is null) + { + continue; + } + + // Skip variants assigned to a different union (C# single inheritance) + if (_unionVariantBaseTypes.TryGetValue(variant, out (string BaseTypeName, string) baseInfo) && + baseInfo.BaseTypeName != typeName) + { + continue; + } + + string discriminatorValue = TryGetDiscriminatorValue(variant, discriminator.PropertyName!) ?? hoistedName; + if (mapping.ContainsKey(discriminatorValue)) + { + continue; + } + + mapping[discriminatorValue] = hoistedName; + } + foreach ((string? discriminatorValue, string? derivedType) in mapping) { AppendLine($"[JsonDerivedType(typeof({derivedType}), \"{discriminatorValue}\")]"); @@ -1215,6 +1412,41 @@ private void EmitDiscriminatedUnion( AppendLine(); } + /// + /// Extracts the wire discriminator value from an inline variant's discriminator property. + /// In OpenAPI 3.0 the value is expressed via (a single + /// ); in OpenAPI 3.1 it is expressed via . + /// Returns null if neither is found. + /// + private static string? TryGetDiscriminatorValue(IOpenApiSchema variant, string discriminatorPropertyName) + { + Dictionary properties = CollectProperties(variant); + if (!properties.TryGetValue(discriminatorPropertyName, out IOpenApiSchema? discProp)) + { + return null; + } + + if (!string.IsNullOrEmpty(discProp.Const)) + { + return discProp.Const; + } + + if (discProp.Enum is null || discProp.Enum.Count == 0) + { + return null; + } + + foreach (var enumValue in discProp.Enum) + { + if (enumValue is JsonValue jv && jv.TryGetValue(out string? s)) + { + return s; + } + } + + return null; + } + private void EmitSimpleUnion(string typeName, IList variants) { // Filter out null-type variants (from nullable anyOf patterns like [type, null]) @@ -1223,25 +1455,51 @@ private void EmitSimpleUnion(string typeName, IList variants) .ToList(); // Collect the variant type names for documentation - var variantNames = nonNullVariants - .OfType() - .Select(v => NameHelper.ToTypeName(v.Reference.Id, _options.ModelPrefix)) - .ToList(); + var variantNames = new List(); + foreach (IOpenApiSchema variant in nonNullVariants) + { + if (variant is OpenApiSchemaReference refVariant) + { + variantNames.Add(NameHelper.ToTypeName(refVariant.Reference.Id, _options.ModelPrefix)); + } + else + { + string? hoistedName = _typeResolver.GetInlineObjectTypeName(variant); + if (hoistedName != null) + { + variantNames.Add(hoistedName); + } + } + } + + bool hasInlineVariant = nonNullVariants.Any(v => v is not OpenApiSchemaReference && _typeResolver.GetInlineObjectTypeName(v) != null); if (variantNames.Count > 0) { AppendLine($"/// "); AppendLine($"/// Union of: {string.Join(" | ", variantNames)}"); + if (hasInlineVariant) + { + AppendLine("/// Inline object variants use the synthesized type name as the discriminator value."); + } AppendLine($"/// "); } - // If all non-null variants are $refs, emit as an abstract record with JsonDerivedType attributes - if (nonNullVariants.Count > 0 && nonNullVariants.All(v => v is OpenApiSchemaReference)) + // Emit [JsonDerivedType] for each resolvable variant + foreach (IOpenApiSchema variant in nonNullVariants) { - foreach (OpenApiSchemaReference variant in nonNullVariants.OfType()) + if (variant is OpenApiSchemaReference refVariant) { - string derivedName = NameHelper.ToTypeName(variant.Reference.Id, _options.ModelPrefix); - AppendLine($"[JsonDerivedType(typeof({derivedName}), \"{variant.Reference.Id}\")]"); + string derivedName = NameHelper.ToTypeName(refVariant.Reference.Id, _options.ModelPrefix); + AppendLine($"[JsonDerivedType(typeof({derivedName}), \"{refVariant.Reference.Id}\")]"); + } + else + { + string? hoistedName = _typeResolver.GetInlineObjectTypeName(variant); + if (hoistedName != null) + { + AppendLine($"[JsonDerivedType(typeof({hoistedName}), \"{hoistedName}\")]"); + } } } diff --git a/src/OpenApiCodeGenerator/TypeResolver.cs b/src/OpenApiCodeGenerator/TypeResolver.cs index 5aa2760..1da1657 100644 --- a/src/OpenApiCodeGenerator/TypeResolver.cs +++ b/src/OpenApiCodeGenerator/TypeResolver.cs @@ -30,6 +30,15 @@ public void RegisterInlineObjectType(IOpenApiSchema schema, string typeName) _inlineObjectTypes[schema] = typeName; } + /// + /// Returns the synthesized type name for an inline object schema if it has been + /// registered via , or null otherwise. + /// + public string? GetInlineObjectTypeName(IOpenApiSchema schema) + { + return _inlineObjectTypes.TryGetValue(schema, out string? name) ? name : null; + } + /// /// Resolve an to a C# type string. /// diff --git a/tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs b/tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs index b1af185..d1b7606 100644 --- a/tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs +++ b/tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs @@ -2197,6 +2197,608 @@ public void Emit_HoistedObjectNameCollisionBetweenTwoHoistedObjects_Differentiat #endregion + #region oneOf/anyOf with Inline Object Variants + + [Fact] + public void Emit_InlineOneOfWithRefAndInlineObject_NotDiscriminated_ResolvesToObject() + { + var schemas = new Dictionary + { + ["A"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["MyRecord"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["value"] = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchemaReference("A"), + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["x"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + } + } + } + } + }; + + string result = Generate(schemas); + + // Non-discriminated union with inline variants resolves to object — + // System.Text.Json can't determine the variant without a discriminator. + Assert.Contains("public object? Value { get; init; }", result, StringComparison.Ordinal); + + // No hoisted union type or inline variant record should be emitted + Assert.DoesNotContain("MyRecordValue", result, StringComparison.Ordinal); + } + + [Fact] + public void Emit_InlineOneOfAllInlineObjects_NotDiscriminated_ResolvesToObject() + { + var schemas = new Dictionary + { + ["MyRecord"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["value"] = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["type"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["type"] = new OpenApiSchema { Type = JsonSchemaType.String }, + ["count"] = new OpenApiSchema { Type = JsonSchemaType.Integer } + } + } + } + } + } + } + }; + + string result = Generate(schemas); + + // Non-discriminated union with all inline variants resolves to object + Assert.Contains("public object? Value { get; init; }", result, StringComparison.Ordinal); + Assert.DoesNotContain("MyRecordValue", result, StringComparison.Ordinal); + } + + [Fact] + public void Emit_ComponentLevelOneOfWithRefAndInlineObject_NotDiscriminated_SkipsInlineVariant() + { + var schemas = new Dictionary + { + ["A"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["MyUnion"] = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchemaReference("A"), + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["label"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + } + } + }; + + string result = Generate(schemas); + + // Component-level non-discriminated union is still emitted as an abstract record + Assert.Contains("public abstract partial record MyUnion;", result, StringComparison.Ordinal); + // The $ref variant gets [JsonDerivedType] + Assert.Contains("[JsonDerivedType(typeof(A), \"A\")]", result, StringComparison.Ordinal); + // Inline variant is NOT hoisted (no discriminator → can't deserialize) + Assert.DoesNotContain("MyUnionVariant2", result, StringComparison.Ordinal); + } + + [Fact] + public async Task Emit_InlineOneOfWithRefAndInlineObject_NotDiscriminated_CompilesSuccessfully() + { + var schemas = new Dictionary + { + ["A"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["MyRecord"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["value"] = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchemaReference("A"), + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["x"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + } + } + } + } + }; + + string result = Generate(schemas); + + string tempRoot = Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..", + "TestResults", "InlineUnionCompile", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + try + { + await File.WriteAllTextAsync(Path.Combine(tempRoot, "Generated.cs"), result, TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(tempRoot, "Harness.csproj"), """ + + + net10.0 + enable + All + true + + + """, TestContext.Current.CancellationToken); + 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(TestContext.Current.CancellationToken); + string stderr = await proc.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken); + await proc.WaitForExitAsync(TestContext.Current.CancellationToken); + Assert.True(proc.ExitCode == 0, + $"Inline union code failed to compile.{Environment.NewLine}STDOUT:{stdout}{Environment.NewLine}STDERR:{stderr}"); + } + finally + { + if (Directory.Exists(tempRoot)) Directory.Delete(tempRoot, recursive: true); + } + } + + [Fact] + public void Emit_InlineAnyOfWithRefAndInlineObject_NotDiscriminated_ResolvesToObject() + { + var schemas = new Dictionary + { + ["A"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["MyRecord"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["value"] = new OpenApiSchema + { + AnyOf = new List + { + new OpenApiSchemaReference("A"), + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["x"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + } + } + } + } + }; + + string result = Generate(schemas); + + // Non-discriminated anyOf with inline variants resolves to object + Assert.Contains("public object? Value { get; init; }", result, StringComparison.Ordinal); + Assert.DoesNotContain("MyRecordValue", result, StringComparison.Ordinal); + } + + [Fact] + public void Emit_InlineOneOfWithNestedInlineObject_NotDiscriminated_ResolvesToObject() + { + var schemas = new Dictionary + { + ["A"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["name"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["MyRecord"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["value"] = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchemaReference("A"), + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["nested"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["deep"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + } + } + } + } + } + } + }; + + string result = Generate(schemas); + + // Non-discriminated union with inline variants resolves to object + Assert.Contains("public object? Value { get; init; }", result, StringComparison.Ordinal); + Assert.DoesNotContain("MyRecordValue", result, StringComparison.Ordinal); + } + + [Fact] + public void Emit_InlineOneOfWithTwoSameShapedInlineObjects_NotDiscriminated_ResolvesToObject() + { + var schemas = new Dictionary + { + ["MyRecord"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["value"] = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["label"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["label"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + } + } + } + } + }; + + string result = Generate(schemas); + + // Non-discriminated union with all inline variants resolves to object + Assert.Contains("public object? Value { get; init; }", result, StringComparison.Ordinal); + Assert.DoesNotContain("MyRecordValue", result, StringComparison.Ordinal); + } + + [Fact] + public void Emit_DiscriminatedOneOfWithRefAndInlineObject_EmitsBothDerivedTypes() + { + var schemas = new Dictionary + { + ["Cat"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "petType" }, + Properties = new Dictionary + { + ["petType"] = new OpenApiSchema { Type = JsonSchemaType.String, Enum = [JsonValue.Create("cat")] }, + ["meow"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["Pet"] = new OpenApiSchema + { + OneOf = + [ + new OpenApiSchemaReference("Cat"), + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "petType" }, + Properties = new Dictionary + { + ["petType"] = new OpenApiSchema { Type = JsonSchemaType.String, Enum = [JsonValue.Create("dog")] }, + ["bark"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + ], + Discriminator = new OpenApiDiscriminator + { + PropertyName = "petType", + Mapping = new Dictionary + { + ["cat"] = new OpenApiSchemaReference("Cat") + } + } + } + }; + + string result = Generate(schemas); + + Assert.Contains("public abstract partial record Pet;", result, StringComparison.Ordinal); + Assert.Contains("[JsonPolymorphic(TypeDiscriminatorPropertyName = \"petType\")]", result, StringComparison.Ordinal); + Assert.Contains("[JsonDerivedType(typeof(Cat), \"cat\")]", result, StringComparison.Ordinal); + Assert.Contains("[JsonDerivedType(typeof(PetVariant2), \"dog\")]", result, StringComparison.Ordinal); + Assert.Contains("public partial record PetVariant2", result, StringComparison.Ordinal); + } + + [Fact] + public void Emit_DiscriminatedUnion_InlineVariantCollidingDiscriminatorValue_DoesNotOverwriteMapping() + { + var schemas = new Dictionary + { + ["Cat"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "petType" }, + Properties = new Dictionary + { + ["petType"] = new OpenApiSchema { Type = JsonSchemaType.String, Enum = [JsonValue.Create("cat")] }, + ["meow"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["Pet"] = new OpenApiSchema + { + OneOf = + [ + new OpenApiSchemaReference("Cat"), + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "petType" }, + Properties = new Dictionary + { + ["petType"] = new OpenApiSchema { Type = JsonSchemaType.String, Enum = [JsonValue.Create("cat")] }, + ["bark"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + ], + Discriminator = new OpenApiDiscriminator + { + PropertyName = "petType", + Mapping = new Dictionary + { + ["cat"] = new OpenApiSchemaReference("Cat") + } + } + } + }; + + string result = Generate(schemas); + + // Explicit mapping entry should be preserved + Assert.Contains("[JsonDerivedType(typeof(Cat), \"cat\")]", result, StringComparison.Ordinal); + // The inline variant's discriminator value "cat" collides with the explicit mapping + // so it should NOT overwrite the mapping entry + Assert.DoesNotContain("[JsonDerivedType(typeof(PetVariant2), \"cat\")]", result, StringComparison.Ordinal); + } + + [Fact] + public void Emit_PropertyLevelDiscriminatedUnion_InlineVariantInheritsFromUnionBase() + { + var schemas = new Dictionary + { + ["Cat"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "petType" }, + Properties = new Dictionary + { + ["petType"] = new OpenApiSchema { Type = JsonSchemaType.String, Enum = [JsonValue.Create("cat")] }, + ["meow"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + ["Owner"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "pet" }, + Properties = new Dictionary + { + ["pet"] = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchemaReference("Cat"), + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "petType" }, + Properties = new Dictionary + { + ["petType"] = new OpenApiSchema { Type = JsonSchemaType.String, Enum = [JsonValue.Create("dog")] }, + ["bark"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + }, + Discriminator = new OpenApiDiscriminator + { + PropertyName = "petType" + } + } + } + } + }; + + string result = Generate(schemas); + + // The property-level union should be an abstract record + Assert.Contains("public abstract partial record OwnerPet;", result, StringComparison.Ordinal); + // The Cat component schema should inherit from the union base type + Assert.Contains("public partial record Cat : OwnerPet", result, StringComparison.Ordinal); + // The inline variant should also inherit from the union base type + Assert.Contains("public partial record OwnerPetVariant2 : OwnerPet", result, StringComparison.Ordinal); + } + + [Fact] + public void Emit_AnyOfWithNullVariantBetweenInlineObjects_NotDiscriminated_ResolvesToObject() + { + var schemas = new Dictionary + { + ["Container"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "value" }, + Properties = new Dictionary + { + ["value"] = new OpenApiSchema + { + AnyOf = new List + { + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["label"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + new OpenApiSchema { Type = JsonSchemaType.Null }, + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["count"] = new OpenApiSchema { Type = JsonSchemaType.Integer, Format = "int32" } + } + } + } + } + } + } + }; + + string result = Generate(schemas); + + // Non-discriminated anyOf with inline variants resolves to object + Assert.Contains("public required object Value { get; init; }", result, StringComparison.Ordinal); + Assert.DoesNotContain("ContainerValue", result, StringComparison.Ordinal); + } + + [Fact] + public void Emit_OneOfWithNullVariantBetweenInlineObjects_NotDiscriminated_ResolvesToObject() + { + var schemas = new Dictionary + { + ["Container"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Required = new HashSet { "value" }, + Properties = new Dictionary + { + ["value"] = new OpenApiSchema + { + OneOf = new List + { + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["label"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + }, + new OpenApiSchema { Type = JsonSchemaType.Null }, + new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["count"] = new OpenApiSchema { Type = JsonSchemaType.Integer, Format = "int32" } + } + } + } + } + } + } + }; + + string result = Generate(schemas); + + // Non-discriminated oneOf with inline variants resolves to object + Assert.Contains("public required object Value { get; init; }", result, StringComparison.Ordinal); + Assert.DoesNotContain("ContainerValue", result, StringComparison.Ordinal); + } + + #endregion + #region Default Value Emission [Fact] diff --git a/tests/OpenApiCodeGenerator.Tests/CSharpSchemaGeneratorTests.cs b/tests/OpenApiCodeGenerator.Tests/CSharpSchemaGeneratorTests.cs index c5537f6..c50430b 100644 --- a/tests/OpenApiCodeGenerator.Tests/CSharpSchemaGeneratorTests.cs +++ b/tests/OpenApiCodeGenerator.Tests/CSharpSchemaGeneratorTests.cs @@ -1040,7 +1040,7 @@ public async Task Generate_ComprehensiveApi_AllOfDerivedRecord_RoundTripsWithSys } [Fact] - public async Task Generate_ComprehensiveApi_OneOfDiscriminatedUnion_DefaultSystemTextJsonReportsUnsupportedDerivedType() + public async Task Generate_ComprehensiveApi_OneOfDiscriminatedUnion_RoundTripsWithSystemTextJsonDefaults() { var generator = new CSharpSchemaGenerator(new GeneratorOptions { @@ -1050,26 +1050,264 @@ public async Task Generate_ComprehensiveApi_OneOfDiscriminatedUnion_DefaultSyste string generatedCode = generator.GenerateFromFile(GetFixturePath("comprehensive-api.json")); string[] lines = await GetSerializationLinesAsync(generatedCode, """ - using System; using System.Text.Json; using GeneratedModels; - try - { - Shape? shape = JsonSerializer.Deserialize("{\"shapeType\":\"circle\",\"radius\":2.5}"); - Console.WriteLine(shape?.GetType().Name ?? ""); - Console.WriteLine(JsonSerializer.Serialize(shape)); - } - catch (Exception ex) - { - Console.WriteLine(ex.GetType().Name); - Console.WriteLine(ex.Message); - } + Shape? shape = JsonSerializer.Deserialize("{\"shapeType\":\"circle\",\"radius\":2.5}"); + Console.WriteLine(shape?.GetType().Name ?? ""); + Console.WriteLine(JsonSerializer.Serialize(shape)); """); - Assert.Equal("InvalidOperationException", lines[^2]); - Assert.Contains("not a supported derived type", lines[^1], StringComparison.Ordinal); - Assert.Contains("GeneratedModels.Shape", lines[^1], StringComparison.Ordinal); + Assert.Equal("Circle", lines[^2]); + Assert.Equal("{\"shapeType\":\"circle\",\"radius\":2.5}", lines[^1]); + } + + [Fact] + public async Task Generate_FromText_DiscriminatedOneOfWithRefAndInlineObject_RoundTripsWithSystemTextJsonDefaults() + { + const string spec = """ + { + "openapi": "3.0.3", + "info": { "title": "Pet Union Test", "version": "1.0.0" }, + "components": { + "schemas": { + "Cat": { + "type": "object", + "required": ["petType"], + "properties": { + "petType": { "type": "string", "enum": ["cat"] }, + "meow": { "type": "string" } + } + }, + "Pet": { + "oneOf": [ + { "$ref": "#/components/schemas/Cat" }, + { + "type": "object", + "required": ["petType"], + "properties": { + "petType": { "type": "string", "enum": ["dog"] }, + "bark": { "type": "string" } + } + } + ], + "discriminator": { + "propertyName": "petType", + "mapping": { "cat": "#/components/schemas/Cat" } + } + } + } + } + } + """; + + var generator = new CSharpSchemaGenerator(new GeneratorOptions + { + GenerateFileHeader = false, + Namespace = "GeneratedModels" + }); + + string generatedCode = generator.GenerateFromText(spec); + string[] lines = await GetSerializationLinesAsync(generatedCode, """ + using System.Text.Json; + using GeneratedModels; + + Pet? cat = JsonSerializer.Deserialize("{\"petType\":\"cat\",\"meow\":\"purr\"}"); + Pet? dog = JsonSerializer.Deserialize("{\"petType\":\"dog\",\"bark\":\"woof\"}"); + Console.WriteLine(cat?.GetType().Name ?? ""); + Console.WriteLine(JsonSerializer.Serialize(cat)); + Console.WriteLine(dog?.GetType().Name ?? ""); + Console.WriteLine(JsonSerializer.Serialize(dog)); + """); + + Assert.Equal("Cat", lines[^4]); + Assert.Equal("{\"petType\":\"cat\",\"meow\":\"purr\"}", lines[^3]); + Assert.Equal("PetVariant2", lines[^2]); + Assert.Equal("{\"petType\":\"dog\",\"bark\":\"woof\"}", lines[^1]); + } + + [Fact] + public async Task Generate_FromText_DiscriminatedOneOfAllInlineObjects_RoundTripsWithSystemTextJsonDefaults() + { + const string spec = """ + { + "openapi": "3.0.3", + "info": { "title": "Animal Union Test", "version": "1.0.0" }, + "components": { + "schemas": { + "Animal": { + "oneOf": [ + { + "type": "object", + "required": ["animalType"], + "properties": { + "animalType": { "type": "string", "enum": ["cat"] }, + "meow": { "type": "string" } + } + }, + { + "type": "object", + "required": ["animalType"], + "properties": { + "animalType": { "type": "string", "enum": ["dog"] }, + "bark": { "type": "string" } + } + } + ], + "discriminator": { + "propertyName": "animalType" + } + } + } + } + } + """; + + var generator = new CSharpSchemaGenerator(new GeneratorOptions + { + GenerateFileHeader = false, + Namespace = "GeneratedModels" + }); + + string generatedCode = generator.GenerateFromText(spec); + string[] lines = await GetSerializationLinesAsync(generatedCode, """ + using System.Text.Json; + using GeneratedModels; + + Animal? cat = JsonSerializer.Deserialize("{\"animalType\":\"cat\",\"meow\":\"purr\"}"); + Animal? dog = JsonSerializer.Deserialize("{\"animalType\":\"dog\",\"bark\":\"woof\"}"); + Console.WriteLine(cat?.GetType().Name ?? ""); + Console.WriteLine(JsonSerializer.Serialize(cat)); + Console.WriteLine(dog?.GetType().Name ?? ""); + Console.WriteLine(JsonSerializer.Serialize(dog)); + """); + + Assert.Equal("AnimalVariant1", lines[^4]); + Assert.Equal("{\"animalType\":\"cat\",\"meow\":\"purr\"}", lines[^3]); + Assert.Equal("AnimalVariant2", lines[^2]); + Assert.Equal("{\"animalType\":\"dog\",\"bark\":\"woof\"}", lines[^1]); + } + + [Fact] + public async Task Generate_FromText_PropertyLevelDiscriminatedUnion_RoundTripsWithSystemTextJsonDefaults() + { + const string spec = """ + { + "openapi": "3.0.3", + "info": { "title": "Property Union Test", "version": "1.0.0" }, + "components": { + "schemas": { + "Cat": { + "type": "object", + "required": ["petType"], + "properties": { + "petType": { "type": "string", "enum": ["cat"] }, + "meow": { "type": "string" } + } + }, + "Owner": { + "type": "object", + "required": ["pet"], + "properties": { + "pet": { + "oneOf": [ + { "$ref": "#/components/schemas/Cat" }, + { + "type": "object", + "required": ["petType"], + "properties": { + "petType": { "type": "string", "enum": ["dog"] }, + "bark": { "type": "string" } + } + } + ], + "discriminator": { + "propertyName": "petType", + "mapping": { + "cat": "#/components/schemas/Cat" + } + } + } + } + } + } + } + } + """; + + var generator = new CSharpSchemaGenerator(new GeneratorOptions + { + GenerateFileHeader = false, + Namespace = "GeneratedModels" + }); + + string generatedCode = generator.GenerateFromText(spec); + string[] lines = await GetSerializationLinesAsync(generatedCode, """ + using System.Text.Json; + using GeneratedModels; + + Owner? catOwner = JsonSerializer.Deserialize("{\"pet\":{\"petType\":\"cat\",\"meow\":\"purr\"}}"); + Owner? dogOwner = JsonSerializer.Deserialize("{\"pet\":{\"petType\":\"dog\",\"bark\":\"woof\"}}"); + Console.WriteLine(catOwner?.Pet?.GetType().Name ?? ""); + Console.WriteLine(JsonSerializer.Serialize(catOwner?.Pet)); + Console.WriteLine(dogOwner?.Pet?.GetType().Name ?? ""); + Console.WriteLine(JsonSerializer.Serialize(dogOwner?.Pet)); + """); + + Assert.Equal("Cat", lines[^4]); + Assert.Equal("{\"petType\":\"cat\",\"meow\":\"purr\"}", lines[^3]); + Assert.Equal("OwnerPetVariant2", lines[^2]); + Assert.Equal("{\"petType\":\"dog\",\"bark\":\"woof\"}", lines[^1]); + } + + [Fact] + public async Task Generate_FromText_DiscriminatedOneOfWithRefAndInlineObject_CompilesWithWarningsAsErrors() + { + const string spec = """ + { + "openapi": "3.0.3", + "info": { "title": "Pet Union Test", "version": "1.0.0" }, + "components": { + "schemas": { + "Cat": { + "type": "object", + "required": ["petType"], + "properties": { + "petType": { "type": "string", "enum": ["cat"] }, + "meow": { "type": "string" } + } + }, + "Pet": { + "oneOf": [ + { "$ref": "#/components/schemas/Cat" }, + { + "type": "object", + "required": ["petType"], + "properties": { + "petType": { "type": "string", "enum": ["dog"] }, + "bark": { "type": "string" } + } + } + ], + "discriminator": { + "propertyName": "petType", + "mapping": { "cat": "#/components/schemas/Cat" } + } + } + } + } + } + """; + + var generator = new CSharpSchemaGenerator(new GeneratorOptions + { + GenerateFileHeader = false, + Namespace = "GeneratedModels" + }); + + string generatedCode = generator.GenerateFromText(spec); + + await AssertGeneratedCodeCompilesAsync(generatedCode, implicitUsings: true, treatWarningsAsErrors: true); } [Fact]