diff --git a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs index b5bdb5953..7302d8d07 100644 --- a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs +++ b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text.Json; using System.Text.Json.Nodes; using SharpYaml; using SharpYaml.Serialization; @@ -133,7 +134,31 @@ ScalarStyle.Plain when YamlNullRepresentations.Contains(yaml.Value) => null, private static YamlScalarNode ToYamlScalar(this JsonValue val) { - return new YamlScalarNode(val.ToJsonString()); + // Try to get the underlying value based on its actual type + // First try to get it as a string + if (val.GetValueKind() == JsonValueKind.String && + val.TryGetValue(out string? stringValue)) + { + // For string values, we need to determine if they should be quoted in YAML + // Strings that look like numbers, booleans, or null need to be quoted + // to preserve their string type when round-tripping + var needsQuoting = decimal.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out _) || + bool.TryParse(stringValue, out _) || + YamlNullRepresentations.Contains(stringValue); + + return new YamlScalarNode(stringValue) + { + Style = needsQuoting ? ScalarStyle.DoubleQuoted : ScalarStyle.Plain + }; + } + + // For non-string values (numbers, booleans, null), use their string representation + // These should remain unquoted in YAML + var valueString = val.ToString(); + return new YamlScalarNode(valueString) + { + Style = ScalarStyle.Plain + }; } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs index c410d4f20..7ed271809 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs @@ -1,7 +1,9 @@ -using SharpYaml; +using SharpYaml; using SharpYaml.Serialization; using Xunit; using Microsoft.OpenApi.YamlReader; +using System.IO; +using System.Text.Json.Nodes; namespace Microsoft.OpenApi.Readers.Tests; @@ -26,4 +28,212 @@ public void YamlNullValuesReturnNullJsonNode(string value) // Then Assert.Null(jsonNode); } + + [Fact] + public void ToYamlNode_StringValue_NotQuotedInYaml() + { + // Arrange + var json = Assert.IsType(JsonNode.Parse(@"{""fooString"": ""fooStringValue""}")); + + // Act + var yamlNode = json.ToYamlNode(); + var yamlOutput = ConvertYamlNodeToString(yamlNode); + + // Assert + Assert.Contains("fooString: fooStringValue", yamlOutput); + Assert.DoesNotContain("\"fooStringValue\"", yamlOutput); + Assert.DoesNotContain("'fooStringValue'", yamlOutput); + } + + [Fact] + public void ToYamlNode_StringThatLooksLikeNumber_QuotedInYaml() + { + // Arrange + var json = Assert.IsType(JsonNode.Parse(@"{""fooStringOfNumber"": ""200""}")); + + // Act + var yamlNode = json.ToYamlNode(); + var yamlOutput = ConvertYamlNodeToString(yamlNode); + + // Assert + Assert.Contains("fooStringOfNumber: \"200\"", yamlOutput); + } + + [Fact] + public void ToYamlNode_ActualNumber_NotQuotedInYaml() + { + // Arrange + var json = Assert.IsType(JsonNode.Parse(@"{""actualNumber"": 200}")); + + // Act + var yamlNode = json.ToYamlNode(); + var yamlOutput = ConvertYamlNodeToString(yamlNode); + + // Assert + Assert.Contains("actualNumber: 200", yamlOutput); + Assert.DoesNotContain("\"200\"", yamlOutput); + } + + [Fact] + public void ToYamlNode_StringThatLooksLikeDecimal_QuotedInYaml() + { + // Arrange + var json = Assert.IsType(JsonNode.Parse(@"{""decimalString"": ""123.45""}")); + + // Act + var yamlNode = json.ToYamlNode(); + var yamlOutput = ConvertYamlNodeToString(yamlNode); + + // Assert + Assert.Contains("decimalString: \"123.45\"", yamlOutput); + } + + [Fact] + public void ToYamlNode_ActualDecimal_NotQuotedInYaml() + { + // Arrange + var json = Assert.IsType(JsonNode.Parse(@"{""actualDecimal"": 123.45}")); + + // Act + var yamlNode = json.ToYamlNode(); + var yamlOutput = ConvertYamlNodeToString(yamlNode); + + // Assert + Assert.Contains("actualDecimal: 123.45", yamlOutput); + Assert.DoesNotContain("\"123.45\"", yamlOutput); + } + + [Fact] + public void ToYamlNode_StringThatLooksLikeBoolean_QuotedInYaml() + { + // Arrange + var json = Assert.IsType(JsonNode.Parse(@"{""boolString"": ""true""}")); + + // Act + var yamlNode = json.ToYamlNode(); + var yamlOutput = ConvertYamlNodeToString(yamlNode); + + // Assert + Assert.Contains("boolString: \"true\"", yamlOutput); + } + + [Fact] + public void ToYamlNode_ActualBoolean_NotQuotedInYaml() + { + // Arrange + var json = Assert.IsType(JsonNode.Parse(@"{""actualBool"": true}")); + + // Act + var yamlNode = json.ToYamlNode(); + var yamlOutput = ConvertYamlNodeToString(yamlNode); + + // Assert + Assert.Contains("actualBool: true", yamlOutput); + Assert.DoesNotContain("\"true\"", yamlOutput); + } + + [Fact] + public void ToYamlNode_StringThatLooksLikeNull_QuotedInYaml() + { + // Arrange + var json = Assert.IsType(JsonNode.Parse(@"{""nullString"": ""null""}")); + + // Act + var yamlNode = json.ToYamlNode(); + var yamlOutput = ConvertYamlNodeToString(yamlNode); + + // Assert + Assert.Contains("nullString: \"null\"", yamlOutput); + } + + [Fact] + public void ToYamlNode_MixedTypes_CorrectQuoting() + { + // Arrange + var json = Assert.IsType(JsonNode.Parse(@"{ + ""str"": ""hello"", + ""numStr"": ""42"", + ""num"": 42, + ""boolStr"": ""false"", + ""bool"": false + }")); + + // Act + var yamlNode = json.ToYamlNode(); + var yamlOutput = ConvertYamlNodeToString(yamlNode); + + // Assert + Assert.Contains("str: hello", yamlOutput); + Assert.Contains("numStr: \"42\"", yamlOutput); + Assert.Contains("num: 42", yamlOutput); + Assert.DoesNotContain("num: \"42\"", yamlOutput); + Assert.Contains("boolStr: \"false\"", yamlOutput); + Assert.Contains("bool: false", yamlOutput); + Assert.DoesNotContain("bool: \"false\"", yamlOutput); + } + + [Fact] + public void ToYamlNode_FromIssueExample_CorrectOutput() + { + // Arrange - Example from issue #1951 + var json = Assert.IsType(JsonNode.Parse(@"{ + ""fooString"": ""fooStringValue"", + ""fooStringOfNumber"": ""200"" + }")); + + // Act + var yamlNode = json.ToYamlNode(); + var yamlOutput = ConvertYamlNodeToString(yamlNode); + + // Assert + Assert.Contains("fooString: fooStringValue", yamlOutput); + Assert.Contains("fooStringOfNumber: \"200\"", yamlOutput); + + // Ensure no extra quotes on regular strings + Assert.DoesNotContain("\"fooStringValue\"", yamlOutput); + Assert.DoesNotContain("'fooStringValue'", yamlOutput); + } + + [Fact] + public void ToYamlNode_StringWithLineBreaks_PreservesLineBreaks() + { + // Arrange + var json = Assert.IsType(JsonNode.Parse(@"{ + ""multiline"": ""Line 1\nLine 2\nLine 3"", + ""description"": ""This is a description\nwith line breaks\nin it"" + }")); + + // Act + var yamlNode = json.ToYamlNode(); + var yamlOutput = ConvertYamlNodeToString(yamlNode); + + // Convert back to JSON to verify round-tripping + var yamlStream = new YamlStream(); + using var sr = new StringReader(yamlOutput); + yamlStream.Load(sr); + var jsonBack = yamlStream.Documents[0].ToJsonNode(); + + // Assert - line breaks should be preserved during round-trip + var originalMultiline = json["multiline"]?.GetValue(); + var roundTripMultiline = jsonBack?["multiline"]?.GetValue(); + Assert.Equal(originalMultiline, roundTripMultiline); + Assert.Contains("\n", roundTripMultiline); + + var originalDescription = json["description"]?.GetValue(); + var roundTripDescription = jsonBack?["description"]?.GetValue(); + Assert.Equal(originalDescription, roundTripDescription); + Assert.Contains("\n", roundTripDescription); + } + + private static string ConvertYamlNodeToString(YamlNode yamlNode) + { + using var ms = new MemoryStream(); + var yamlStream = new YamlStream(new YamlDocument(yamlNode)); + var writer = new StreamWriter(ms); + yamlStream.Save(writer); + writer.Flush(); + ms.Seek(0, SeekOrigin.Begin); + var reader = new StreamReader(ms); + return reader.ReadToEnd(); + } }