Skip to content

Commit 4284c5d

Browse files
authored
Handle special characters in Json and Yaml writers (#104)
* Escape control characters and special characters in Json and Yaml writers. * Add unit tests for special characters handling in Json and Yaml writers
1 parent b382cec commit 4284c5d

File tree

10 files changed

+314
-48
lines changed

10 files changed

+314
-48
lines changed

src/Microsoft.OpenApi.Readers/Microsoft.OpenApi.Readers.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<Company>Microsoft</Company>
77
<Product>Microsoft.OpenApi.Readers</Product>
88
<PackageId>Microsoft.OpenApi.Readers</PackageId>
9-
<Version>1.0.0-beta005</Version>
9+
<Version>1.0.0-beta006</Version>
1010
<Description>OpenAPI.NET Readers for JSON and YAML documents</Description>
1111
<AssemblyName>Microsoft.OpenApi.Readers</AssemblyName>
1212
<RootNamespace>Microsoft.OpenApi.Readers</RootNamespace>

src/Microsoft.OpenApi/Microsoft.OpenApi.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<Company>Microsoft</Company>
77
<Product>Microsoft.OpenApi</Product>
88
<PackageId>Microsoft.OpenApi</PackageId>
9-
<Version>1.0.0-beta005</Version>
9+
<Version>1.0.0-beta006</Version>
1010
<Description>.NET models and JSON/YAML writers for OpenAPI specification</Description>
1111
<AssemblyName>Microsoft.OpenApi</AssemblyName>
1212
<RootNamespace>Microsoft.OpenApi</RootNamespace>

src/Microsoft.OpenApi/Writers/OpenApiJsonWriter.cs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public OpenApiJsonWriter(TextWriter textWriter, OpenApiSerializerSettings settin
3232
}
3333

3434
/// <summary>
35-
/// Base Indentation Level.
35+
/// Base Indentation Level.
3636
/// This denotes how many indentations are needed for the property in the base object.
3737
/// </summary>
3838
protected override int BaseIndentation => 1;
@@ -150,12 +150,13 @@ public override void WritePropertyName(string name)
150150
Writer.WriteLine();
151151

152152
currentScope.ObjectCount++;
153-
153+
154154
WriteIndentation();
155155

156-
Writer.Write(WriterConstants.QuoteCharacter);
156+
name = name.GetJsonCompatibleString();
157+
157158
Writer.Write(name);
158-
Writer.Write(WriterConstants.QuoteCharacter);
159+
159160
Writer.Write(WriterConstants.NameValueSeparator);
160161
}
161162

@@ -167,11 +168,9 @@ public override void WriteValue(string value)
167168
{
168169
WriteValueSeparator();
169170

170-
value = value.Replace("\n", "\\n");
171-
172-
Writer.Write(WriterConstants.QuoteCharacter);
171+
value = value.GetJsonCompatibleString();
172+
173173
Writer.Write(value);
174-
Writer.Write(WriterConstants.QuoteCharacter);
175174
}
176175

177176
/// <summary>

src/Microsoft.OpenApi/Writers/OpenApiYamlWriter.cs

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// ------------------------------------------------------------
55

66
using System.IO;
7+
using System.Linq;
78

89
namespace Microsoft.OpenApi.Writers
910
{
@@ -30,9 +31,9 @@ public OpenApiYamlWriter(TextWriter textWriter, OpenApiSerializerSettings settin
3031
: base(textWriter, settings)
3132
{
3233
}
33-
34+
3435
/// <summary>
35-
/// Base Indentation Level.
36+
/// Base Indentation Level.
3637
/// This denotes how many indentations are needed for the property in the base object.
3738
/// </summary>
3839
protected override int BaseIndentation => 0;
@@ -147,12 +148,14 @@ public override void WritePropertyName(string name)
147148
// Only add newline and indentation when this object is not in the top level scope and not in an array.
148149
// The top level scope should have no indentation and it is already in its own line.
149150
// The first property of an object inside array can go after the array prefix (-) directly.
150-
else if (! IsTopLevelScope() && !currentScope.IsInArray )
151+
else if (!IsTopLevelScope() && !currentScope.IsInArray)
151152
{
152153
Writer.WriteLine();
153154
WriteIndentation();
154155
}
155156

157+
name = name.GetYamlCompatibleString();
158+
156159
Writer.Write(name);
157160
Writer.Write(":");
158161

@@ -167,32 +170,7 @@ public override void WriteValue(string value)
167170
{
168171
WriteValueSeparator();
169172

170-
value = value.Replace("\n", "\\n");
171-
172-
// If string is an empty string, wrap it in quote to ensure it is not recognized as null.
173-
if (value == "")
174-
{
175-
value = "''";
176-
}
177-
178-
// If string is the word null, wrap it in quote to ensure it is not recognized as empty scalar null.
179-
if (value == "null")
180-
{
181-
value = "'null'";
182-
}
183-
184-
// If string includes special character, wrap it in quote to avoid conflicts.
185-
if (value.StartsWith("#"))
186-
{
187-
value = $"'{value}'";
188-
}
189-
190-
// If string can be mistaken as a number or a boolean, wrap it in quote to indicate that this is
191-
// indeed a string, not a number of a boolean.
192-
if (decimal.TryParse(value, out var _) || bool.TryParse(value, out var _))
193-
{
194-
value = $"'{value}'";
195-
}
173+
value = value.GetYamlCompatibleString();
196174

197175
Writer.Write(value);
198176
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// ------------------------------------------------------------
2+
// Copyright (c) Microsoft Corporation. All rights reserved.
3+
// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
4+
// ------------------------------------------------------------
5+
6+
using System;
7+
using System.Linq;
8+
9+
namespace Microsoft.OpenApi.Writers
10+
{
11+
/// <summary>
12+
/// Extensions class for strings to handle special characters.
13+
/// </summary>
14+
public static class SpecialCharacterStringExtensions
15+
{
16+
// Plain style strings cannot start with indicators.
17+
// http://www.yaml.org/spec/1.2/spec.html#indicator//
18+
private static readonly char[] _yamlIndicators =
19+
{
20+
'-',
21+
'?',
22+
':',
23+
',',
24+
'{',
25+
'}',
26+
'[',
27+
']',
28+
'&',
29+
'*',
30+
'#',
31+
'?',
32+
'|',
33+
'-',
34+
'>',
35+
'!',
36+
'%',
37+
'@',
38+
'`',
39+
'\'',
40+
'"',
41+
};
42+
43+
// Plain style strings cannot contain these character combinations.
44+
// http://www.yaml.org/spec/1.2/spec.html#style/flow/plain
45+
private static readonly string[] _yamlPlainStringForbiddenCombinations =
46+
{
47+
": ",
48+
" #",
49+
50+
// These are technically forbidden only inside flow collections, but
51+
// for the sake of simplicity, we will never allow them in our generated plain string.
52+
"[",
53+
"]",
54+
"{",
55+
"}",
56+
","
57+
};
58+
59+
// Double-quoted strings are needed for these non-printable control characters.
60+
// http://www.yaml.org/spec/1.2/spec.html#style/flow/double-quoted
61+
private static readonly char[] _yamlControlCharacters =
62+
{
63+
'\0',
64+
'\x01',
65+
'\x02',
66+
'\x03',
67+
'\x04',
68+
'\x05',
69+
'\x06',
70+
'\a',
71+
'\b',
72+
'\t',
73+
'\n',
74+
'\v',
75+
'\f',
76+
'\r',
77+
'\x0e',
78+
'\x0f',
79+
'\x10',
80+
'\x11',
81+
'\x12',
82+
'\x13',
83+
'\x14',
84+
'\x15',
85+
'\x16',
86+
'\x17',
87+
'\x18',
88+
'\x19',
89+
'\x1a',
90+
'\x1b',
91+
'\x1c',
92+
'\x1d',
93+
'\x1e',
94+
'\x1f'
95+
};
96+
97+
/// <summary>
98+
/// Escapes all special characters and put the string in quotes if necessary to
99+
/// get a YAML-compatible string.
100+
/// </summary>
101+
internal static string GetYamlCompatibleString(this string input)
102+
{
103+
// If string is an empty string, wrap it in quote to ensure it is not recognized as null.
104+
if (input == "")
105+
{
106+
return "''";
107+
}
108+
109+
// If string is the word null, wrap it in quote to ensure it is not recognized as empty scalar null.
110+
if (input == "null")
111+
{
112+
return "'null'";
113+
}
114+
115+
// If string is the letter ~, wrap it in quote to ensure it is not recognized as empty scalar null.
116+
if (input == "~")
117+
{
118+
return "'~'";
119+
}
120+
121+
// If string includes a control character, wrapping in double quote is required.
122+
if (input.Any(c => _yamlControlCharacters.Contains(c)))
123+
{
124+
// Replace the backslash first, so that the new backslashes created by other Replaces are not duplicated.
125+
input = input.Replace("\\", "\\\\");
126+
127+
// Escape the double quotes.
128+
input = input.Replace("\"", "\\\"");
129+
130+
// Escape all the control characters.
131+
input = input.Replace("\0", "\\0");
132+
input = input.Replace("\x01", "\\x01");
133+
input = input.Replace("\x02", "\\x02");
134+
input = input.Replace("\x03", "\\x03");
135+
input = input.Replace("\x04", "\\x04");
136+
input = input.Replace("\x05", "\\x05");
137+
input = input.Replace("\x06", "\\x06");
138+
input = input.Replace("\a", "\\a");
139+
input = input.Replace("\b", "\\b");
140+
input = input.Replace("\t", "\\t");
141+
input = input.Replace("\n", "\\n");
142+
input = input.Replace("\v", "\\v");
143+
input = input.Replace("\f", "\\f");
144+
input = input.Replace("\r", "\\r");
145+
input = input.Replace("\x0e", "\\x0e");
146+
input = input.Replace("\x0f", "\\x0f");
147+
input = input.Replace("\x10", "\\x10");
148+
input = input.Replace("\x11", "\\x11");
149+
input = input.Replace("\x12", "\\x12");
150+
input = input.Replace("\x13", "\\x13");
151+
input = input.Replace("\x14", "\\x14");
152+
input = input.Replace("\x15", "\\x15");
153+
input = input.Replace("\x16", "\\x16");
154+
input = input.Replace("\x17", "\\x17");
155+
input = input.Replace("\x18", "\\x18");
156+
input = input.Replace("\x19", "\\x19");
157+
input = input.Replace("\x1a", "\\x1a");
158+
input = input.Replace("\x1b", "\\x1b");
159+
input = input.Replace("\x1c", "\\x1c");
160+
input = input.Replace("\x1d", "\\x1d");
161+
input = input.Replace("\x1e", "\\x1e");
162+
input = input.Replace("\x1f", "\\x1f");
163+
164+
return $"\"{input}\"";
165+
}
166+
167+
// If string
168+
// 1) includes a character forbidden in plain string,
169+
// 2) starts with an indicator, OR
170+
// 3) has trailing/leading white spaces,
171+
// wrap the string in single quote.
172+
// http://www.yaml.org/spec/1.2/spec.html#style/flow/plain
173+
if (_yamlPlainStringForbiddenCombinations.Any( fc => input.Contains(fc) )
174+
|| _yamlIndicators.Any( i => input.StartsWith(i.ToString()) )
175+
|| input.Trim() != input)
176+
{
177+
// Escape single quotes with two single quotes.
178+
input = input.Replace("'", "''");
179+
180+
return $"'{input}'";
181+
}
182+
183+
// If string can be mistaken as a number, a boolean, or a timestamp,
184+
// wrap it in quote to indicate that this is indeed a string, not a number, a boolean, or a timestamp
185+
if (decimal.TryParse(input, out var _) || bool.TryParse(input, out var _)
186+
|| DateTime.TryParse(input, out var _))
187+
{
188+
return $"'{input}'";
189+
}
190+
191+
return input;
192+
}
193+
194+
/// <summary>
195+
/// Handles control characters and backslashes and adds double quotes
196+
/// to get JSON-compatible string.
197+
/// </summary>
198+
internal static string GetJsonCompatibleString(this string value)
199+
{
200+
// Show the control characters as strings
201+
// http://json.org/
202+
203+
// Replace the backslash first, so that the new backslashes created by other Replaces are not duplicated.
204+
value = value.Replace("\\", "\\\\");
205+
206+
value = value.Replace("\b", "\\b");
207+
value = value.Replace("\f", "\\f");
208+
value = value.Replace("\n", "\\n");
209+
value = value.Replace("\r", "\\r");
210+
value = value.Replace("\t", "\\t");
211+
value = value.Replace("\"", "\\\"");
212+
213+
return $"\"{value}\"";
214+
}
215+
}
216+
}

src/Microsoft.OpenApi/Writers/WriterConstants.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,6 @@ internal static class WriterConstants
8585
/// </summary>
8686
internal const string NameValueSeparator = ": ";
8787

88-
/// <summary>
89-
/// The quote character.
90-
/// </summary>
91-
internal const char QuoteCharacter = '"';
92-
9388
/// <summary>
9489
/// The white space for empty object
9590
/// </summary>

test/Microsoft.OpenApi.Tests/Models/OpenApiEncodingTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public void SerializeAdvanceEncodingAsV3YamlWorks()
6161
{
6262
// Arrange
6363
string expected =
64-
@"contentType: image/png, image/jpeg
64+
@"contentType: 'image/png, image/jpeg'
6565
style: simple
6666
explode: true
6767
allowReserved: true";

test/Microsoft.OpenApi.Tests/Models/OpenApiInfoTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ public static IEnumerable<object[]> AdvanceInfoYamlExpect()
154154
name: Apache 2.0
155155
url: http://www.apache.org/licenses/LICENSE-2.0.html
156156
x-copyright: Abc
157-
version: 1.1.1
157+
version: '1.1.1'
158158
x-updated: metadata"
159159
};
160160
}
@@ -185,7 +185,7 @@ public void InfoVersionShouldAcceptDateStyledAsVersions()
185185

186186
var expected =
187187
@"title: Sample Pet Store App
188-
version: 2017-03-01";
188+
version: '2017-03-01'";
189189

190190
// Act
191191
var actual = info.Serialize(OpenApiSpecVersion.OpenApi3_0_0, OpenApiFormat.Yaml);

test/Microsoft.OpenApi.Tests/Models/OpenApiMediaTypeTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public void SerializeAdvanceMediaTypeAsV3YamlWorks()
7272
@"example: 42
7373
encoding:
7474
testEncoding:
75-
contentType: image/png, image/jpeg
75+
contentType: 'image/png, image/jpeg'
7676
style: simple
7777
explode: true
7878
allowReserved: true";

0 commit comments

Comments
 (0)