diff --git a/src/OpenApiCodeGenerator/CSharpCodeEmitter.cs b/src/OpenApiCodeGenerator/CSharpCodeEmitter.cs index 3903097..7ec2cae 100644 --- a/src/OpenApiCodeGenerator/CSharpCodeEmitter.cs +++ b/src/OpenApiCodeGenerator/CSharpCodeEmitter.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text; using System.Text.Json.Nodes; @@ -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); + } + + /// + /// Checks whether the schema's string format maps to a validation attribute. + /// + 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() @@ -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; diff --git a/tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs b/tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs index b1af185..fc9e04a 100644 --- a/tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs +++ b/tests/OpenApiCodeGenerator.Tests/CSharpCodeEmitterTests.cs @@ -3183,6 +3183,159 @@ await File.WriteAllTextAsync(Path.Combine(tempRoot, "Harness.csproj"), """ #endregion + #region Format-Based Validation Attributes + + [Fact] + public void Emit_StringWithEmailFormat_EmitsEmailAddressAttribute() + { + var schemas = new Dictionary + { + ["User"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["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 + { + ["Contact"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["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 + { + ["User"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["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 + { + ["User"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["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 + { + ["Contact"] = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["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); + 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(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]