Skip to content
Open
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
33 changes: 32 additions & 1 deletion src/OpenApiCodeGenerator/CSharpCodeEmitter.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using System.Text.Json.Nodes;
Expand Down Expand Up @@ -168,7 +169,27 @@ private static bool HasValidationConstraints(IOpenApiSchema schema)
!string.IsNullOrEmpty(schema.Minimum) ||
!string.IsNullOrEmpty(schema.Maximum) ||
schema.MinItems.HasValue ||
schema.MaxItems.HasValue;
schema.MaxItems.HasValue ||
HasFormatValidationAttribute(schema);
}

/// <summary>
/// Checks whether the schema's string format maps to a validation attribute.
/// </summary>
private static bool HasFormatValidationAttribute(IOpenApiSchema schema)
{
return GetFormatValidationAttribute(schema.Format) != null;
}

[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Intended")]
private static string? GetFormatValidationAttribute(string? format)
{
return format?.ToLowerInvariant() switch
{
"email" => "[EmailAddress]",
"phone" => "[Phone]",
_ => null
};
}

private void EmitTypeAliasInterface()
Expand Down Expand Up @@ -1497,6 +1518,16 @@ private void EmitValidationAttributes(IOpenApiSchema schema)
bool isArray = TypeResolver.HasTypeFlag(schema, JsonSchemaType.Array);
bool isNumber = TypeResolver.HasTypeFlag(schema, JsonSchemaType.Number) || TypeResolver.HasTypeFlag(schema, JsonSchemaType.Integer);

// Format-based validation attributes for strings
if (isString)
{
string? formatAttr = GetFormatValidationAttribute(schema.Format);
if (formatAttr != null)
{
AppendLine(formatAttr);
}
}

if (isString)
{
int? minLength = schema.MinLength;
Expand Down
153 changes: 153 additions & 0 deletions tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3183,6 +3183,159 @@

#endregion

#region Format-Based Validation Attributes

[Fact]
public void Emit_StringWithEmailFormat_EmitsEmailAddressAttribute()
{
var schemas = new Dictionary<string, IOpenApiSchema>
{
["User"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["email"] = new OpenApiSchema { Type = JsonSchemaType.String, Format = "email" }
}
}
};

string result = Generate(schemas);

Assert.Contains("[EmailAddress]", result, StringComparison.Ordinal);
Assert.Contains("using System.ComponentModel.DataAnnotations;", result, StringComparison.Ordinal);
}

[Fact]
public void Emit_StringWithPhoneFormat_EmitsPhoneAttribute()
{
var schemas = new Dictionary<string, IOpenApiSchema>
{
["Contact"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["phone"] = new OpenApiSchema { Type = JsonSchemaType.String, Format = "phone" }
}
}
};

string result = Generate(schemas);

Assert.Contains("[Phone]", result, StringComparison.Ordinal);
Assert.Contains("using System.ComponentModel.DataAnnotations;", result, StringComparison.Ordinal);
}

[Fact]
public void Emit_StringWithEmailFormat_WithValidationDisabled_DoesNotEmitEmailAddress()
{
var schemas = new Dictionary<string, IOpenApiSchema>
{
["User"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["email"] = new OpenApiSchema { Type = JsonSchemaType.String, Format = "email" }
}
}
};

string result = Generate(schemas, new GeneratorOptions
{
GenerateFileHeader = false,
Namespace = "TestModels",
EmitValidationAttributes = false
});

Assert.DoesNotContain("[EmailAddress]", result, StringComparison.Ordinal);
Assert.DoesNotContain("System.ComponentModel.DataAnnotations", result, StringComparison.Ordinal);
}

[Fact]
public void Emit_StringWithUnknownFormat_DoesNotEmitFormatValidationAttribute()
{
var schemas = new Dictionary<string, IOpenApiSchema>
{
["User"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["hostname"] = new OpenApiSchema { Type = JsonSchemaType.String, Format = "hostname" }
}
}
};

string result = Generate(schemas);

// hostname is not a recognized format — no validation attribute
Assert.DoesNotContain("[EmailAddress]", result, StringComparison.Ordinal);
Assert.DoesNotContain("[Phone]", result, StringComparison.Ordinal);
}

[Fact]
public async Task Emit_EmailAndPhoneFormats_CompilesSuccessfully()
{
var schemas = new Dictionary<string, IOpenApiSchema>
{
["Contact"] = new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["email"] = new OpenApiSchema { Type = JsonSchemaType.String, Format = "email" },
["phone"] = new OpenApiSchema { Type = JsonSchemaType.String, Format = "phone" }
}
}
};

string result = Generate(schemas);

string tempRoot = Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "..",
"TestResults", "FormatValidationCompile", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
try
{
await File.WriteAllTextAsync(Path.Combine(tempRoot, "Generated.cs"), result);

Check warning on line 3302 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 3302 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 3303 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 3303 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(TestContext.Current.CancellationToken);
string stderr = await proc.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken);
await proc.WaitForExitAsync(TestContext.Current.CancellationToken);
Assert.True(proc.ExitCode == 0,
$"Format validation code failed to compile.{Environment.NewLine}STDOUT:{stdout}{Environment.NewLine}STDERR:{stderr}");
}
finally
{
if (Directory.Exists(tempRoot)) Directory.Delete(tempRoot, recursive: true);
}
}

#endregion

#region Deprecated / Obsolete

[Fact]
Expand Down