Skip to content
Merged
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
27 changes: 26 additions & 1 deletion src/Microsoft.OpenApi.YamlReader/YamlConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
};
}
}
}
212 changes: 211 additions & 1 deletion test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -26,4 +28,212 @@ public void YamlNullValuesReturnNullJsonNode(string value)
// Then
Assert.Null(jsonNode);
}

[Fact]
public void ToYamlNode_StringValue_NotQuotedInYaml()
{
// Arrange
var json = Assert.IsType<JsonObject>(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<JsonObject>(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<JsonObject>(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<JsonObject>(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<JsonObject>(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<JsonObject>(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<JsonObject>(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<JsonObject>(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<JsonObject>(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<JsonObject>(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<JsonObject>(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<string>();
var roundTripMultiline = jsonBack?["multiline"]?.GetValue<string>();
Assert.Equal(originalMultiline, roundTripMultiline);
Assert.Contains("\n", roundTripMultiline);

var originalDescription = json["description"]?.GetValue<string>();
var roundTripDescription = jsonBack?["description"]?.GetValue<string>();
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();
}
}
Loading