From 19856cdbbfcba7d77ba2e8f4f96eeaf06ce13320 Mon Sep 17 00:00:00 2001 From: Thyago-Oliveira-Perez Date: Thu, 16 Apr 2026 21:37:59 -0300 Subject: [PATCH] feat: add TOML v1.0 serialization writer Adds OpenApiTomlWriter (mirrors OpenApiJsonWriter / OpenApiYamlWriter), a new OpenApiConstants.Toml constant, and SerializeAsTomlAsync extension methods so callers can export an OpenAPI document as TOML. Public API declared in PublicAPI.Unshipped.txt; RS0026 suppressed for consistency with existing SerializeAsJson/YamlAsync overloads. --- .../OpenApiSerializableExtensions.cs | 31 ++ src/Microsoft.OpenApi/GlobalSuppressions.cs | 2 + .../Models/OpenApiConstants.cs | 5 + src/Microsoft.OpenApi/PublicAPI.Unshipped.txt | 25 + .../Writers/OpenApiTomlWriter.cs | 481 ++++++++++++++++++ .../Writers/OpenApiTomlWriterTests.cs | 374 ++++++++++++++ 6 files changed, 918 insertions(+) create mode 100644 src/Microsoft.OpenApi/Writers/OpenApiTomlWriter.cs create mode 100644 test/Microsoft.OpenApi.Tests/Writers/OpenApiTomlWriterTests.cs diff --git a/src/Microsoft.OpenApi/Extensions/OpenApiSerializableExtensions.cs b/src/Microsoft.OpenApi/Extensions/OpenApiSerializableExtensions.cs index a1f2aca4d..6536f3b3a 100755 --- a/src/Microsoft.OpenApi/Extensions/OpenApiSerializableExtensions.cs +++ b/src/Microsoft.OpenApi/Extensions/OpenApiSerializableExtensions.cs @@ -41,6 +41,20 @@ public static Task SerializeAsYamlAsync(this T element, Stream stream, OpenAp return element.SerializeAsync(stream, specVersion, OpenApiConstants.Yaml, cancellationToken); } + /// + /// Serializes the to the Open API document (TOML) using the given stream and specification version. + /// + /// the + /// The Open API element. + /// The output stream. + /// The Open API specification version. + /// The cancellation token. + public static Task SerializeAsTomlAsync(this T element, Stream stream, OpenApiSpecVersion specVersion, CancellationToken cancellationToken = default) + where T : IOpenApiSerializable + { + return element.SerializeAsync(stream, specVersion, OpenApiConstants.Toml, cancellationToken); + } + /// /// Serializes the to the Open API document using /// the given stream, specification version and the format. @@ -91,6 +105,7 @@ public static Task SerializeAsync( OpenApiConstants.Json when settings is OpenApiJsonWriterSettings jsonSettings => new OpenApiJsonWriter(streamWriter, jsonSettings), OpenApiConstants.Json => new OpenApiJsonWriter(streamWriter, settings), OpenApiConstants.Yaml => new OpenApiYamlWriter(streamWriter, settings), + OpenApiConstants.Toml => new OpenApiTomlWriter(streamWriter, settings), _ => throw new OpenApiException(string.Format(SRResource.OpenApiFormatNotSupported, format)), }; return element.SerializeAsync(writer, specVersion, cancellationToken); @@ -167,6 +182,22 @@ public static Task SerializeAsYamlAsync( return element.SerializeAsync(specVersion, OpenApiConstants.Yaml, cancellationToken); } + /// + /// Serializes the to the Open API document as a string in TOML format. + /// + /// the + /// The Open API element. + /// The Open API specification version. + /// The cancellation token. + public static Task SerializeAsTomlAsync( + this T element, + OpenApiSpecVersion specVersion, + CancellationToken cancellationToken = default) + where T : IOpenApiSerializable + { + return element.SerializeAsync(specVersion, OpenApiConstants.Toml, cancellationToken); + } + /// /// Serializes the to the Open API document as a string in the given format. /// diff --git a/src/Microsoft.OpenApi/GlobalSuppressions.cs b/src/Microsoft.OpenApi/GlobalSuppressions.cs index 2034c3b34..93269555d 100644 --- a/src/Microsoft.OpenApi/GlobalSuppressions.cs +++ b/src/Microsoft.OpenApi/GlobalSuppressions.cs @@ -6,3 +6,5 @@ using System.Diagnostics.CodeAnalysis; [assembly: SuppressMessage("Style", "IDE0130:Namespace does not match folder structure", Justification = "", Scope = "namespace", Target = "~N:Microsoft.OpenApi")] +[assembly: SuppressMessage("ApiDesign", "RS0026:Do not add multiple overloads with optional parameters", Justification = "Consistent with existing SerializeAsJsonAsync and SerializeAsYamlAsync overloads.", Scope = "member", Target = "~M:Microsoft.OpenApi.OpenApiSerializableExtensions.SerializeAsTomlAsync``1(``0,System.IO.Stream,Microsoft.OpenApi.OpenApiSpecVersion,System.Threading.CancellationToken)~System.Threading.Tasks.Task")] +[assembly: SuppressMessage("ApiDesign", "RS0026:Do not add multiple overloads with optional parameters", Justification = "Consistent with existing SerializeAsJsonAsync and SerializeAsYamlAsync overloads.", Scope = "member", Target = "~M:Microsoft.OpenApi.OpenApiSerializableExtensions.SerializeAsTomlAsync``1(``0,Microsoft.OpenApi.OpenApiSpecVersion,System.Threading.CancellationToken)~System.Threading.Tasks.Task{System.String}")] diff --git a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs index a54758002..8609076b3 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiConstants.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiConstants.cs @@ -30,6 +30,11 @@ public static class OpenApiConstants /// public const string Yml = "yml"; + /// + /// Field: Toml + /// + public const string Toml = "toml"; + /// /// Field: Info /// diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index 7dc5c5811..1ba87d777 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1 +1,26 @@ #nullable enable +const Microsoft.OpenApi.OpenApiConstants.Toml = "toml" -> string! +Microsoft.OpenApi.OpenApiTomlWriter +Microsoft.OpenApi.OpenApiTomlWriter.OpenApiTomlWriter(System.IO.TextWriter! textWriter) -> void +Microsoft.OpenApi.OpenApiTomlWriter.OpenApiTomlWriter(System.IO.TextWriter! textWriter, Microsoft.OpenApi.OpenApiWriterSettings? settings) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.BaseIndentation.get -> int +override Microsoft.OpenApi.OpenApiTomlWriter.WriteEndArray() -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteEndObject() -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteNull() -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WritePropertyName(string! name) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteRaw(string! value) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteStartArray() -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteStartObject() -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteValue(bool value) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteValue(decimal value) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteValue(double value) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteValue(float value) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteValue(int value) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteValue(long value) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteValue(object? value) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteValue(string! value) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteValue(System.DateTime value) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteValue(System.DateTimeOffset value) -> void +override Microsoft.OpenApi.OpenApiTomlWriter.WriteValueSeparator() -> void +static Microsoft.OpenApi.OpenApiSerializableExtensions.SerializeAsTomlAsync(this T element, Microsoft.OpenApi.OpenApiSpecVersion specVersion, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Microsoft.OpenApi.OpenApiSerializableExtensions.SerializeAsTomlAsync(this T element, System.IO.Stream! stream, Microsoft.OpenApi.OpenApiSpecVersion specVersion, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Microsoft.OpenApi/Writers/OpenApiTomlWriter.cs b/src/Microsoft.OpenApi/Writers/OpenApiTomlWriter.cs new file mode 100644 index 000000000..eaf56285c --- /dev/null +++ b/src/Microsoft.OpenApi/Writers/OpenApiTomlWriter.cs @@ -0,0 +1,481 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.OpenApi +{ + /// + /// TOML writer for Open API documents. + /// + /// + /// Produces TOML v1.0 output. Scalar values and inline arrays are written as + /// key = value pairs; sub-tables are written as [section] headers; arrays of + /// objects are written as [[array]] headers. + /// TOML does not have a native null type, so null values are omitted. + /// + public class OpenApiTomlWriter : OpenApiWriterBase, IOpenApiWriter + { + private static readonly Regex BareKeyPattern = new(@"^[A-Za-z0-9_-]+$", RegexOptions.Compiled); + + // Root table of the buffered document + private TomlTable? _root; + + // Stack of currently open nodes (TomlTable or TomlArray) + private readonly Stack _nodeStack = new(); + + // Property name pending to be used as the key for the next value + private string? _pendingKey; + + /// + /// Initializes a new instance of the class. + /// + /// The output text writer. + public OpenApiTomlWriter(TextWriter textWriter) : this(textWriter, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The output text writer. + /// Settings for controlling how the document is written. + public OpenApiTomlWriter(TextWriter textWriter, OpenApiWriterSettings? settings) + : base(textWriter, settings) + { + } + + /// + protected override int BaseIndentation => 0; + + /// + public override void WriteStartObject() + { + StartScope(ScopeType.Object); + var table = new TomlTable(); + if (_root is null && _nodeStack.Count == 0) + { + _root = table; + } + else + { + AddToParent(table); + } + _nodeStack.Push(table); + } + + /// + public override void WriteEndObject() + { + EndScope(ScopeType.Object); + _nodeStack.Pop(); + } + + /// + public override void WriteStartArray() + { + StartScope(ScopeType.Array); + var array = new TomlArray(); + AddToParent(array); + _nodeStack.Push(array); + } + + /// + public override void WriteEndArray() + { + EndScope(ScopeType.Array); + _nodeStack.Pop(); + } + + /// + public override void WritePropertyName(string name) + { + VerifyCanWritePropertyName(name); + CurrentScope()!.ObjectCount++; + _pendingKey = name; + } + + /// + public override void WriteValue(string value) => + AddToParent(new TomlScalar(TomlScalarKind.String, value)); + + /// + public override void WriteValue(int value) => + AddToParent(new TomlScalar(TomlScalarKind.Integer, value)); + + /// + public override void WriteValue(bool value) => + AddToParent(new TomlScalar(TomlScalarKind.Boolean, value)); + + /// + public override void WriteValue(decimal value) => + AddToParent(new TomlScalar(TomlScalarKind.Float, value)); + + /// + public override void WriteValue(float value) => + AddToParent(new TomlScalar(TomlScalarKind.Float, (double)value)); + + /// + public override void WriteValue(double value) => + AddToParent(new TomlScalar(TomlScalarKind.Float, value)); + + /// + public override void WriteValue(long value) => + AddToParent(new TomlScalar(TomlScalarKind.Integer, value)); + + /// + public override void WriteValue(DateTime value) => + AddToParent(new TomlScalar(TomlScalarKind.String, value.ToString("o", CultureInfo.InvariantCulture))); + + /// + public override void WriteValue(DateTimeOffset value) => + AddToParent(new TomlScalar(TomlScalarKind.String, value.ToString("o", CultureInfo.InvariantCulture))); + + /// + public override void WriteValue(object? value) + { + if (value is null) { WriteNull(); return; } + if (value is string s) WriteValue(s); + else if (value is int i) WriteValue(i); + else if (value is uint u) WriteValue((long)u); + else if (value is long l) WriteValue(l); + else if (value is bool b) WriteValue(b); + else if (value is float f) WriteValue(f); + else if (value is double d) WriteValue(d); + else if (value is decimal dc) WriteValue(dc); + else if (value is DateTime dt) WriteValue(dt); + else if (value is DateTimeOffset dto) WriteValue(dto); + else if (value is System.Collections.Generic.IEnumerable en) WriteEnumerable(en); + else throw new OpenApiWriterException(string.Format(SRResource.OpenApiUnsupportedValueType, value.GetType().FullName)); + } + + /// + public override void WriteNull() => AddToParent(TomlNull.Instance); + + /// + public override void WriteRaw(string value) => + AddToParent(new TomlScalar(TomlScalarKind.Raw, value)); + + /// + protected override void WriteValueSeparator() + { + // No-op: the tree structure manages value ordering. + } + + /// + async Task IOpenApiWriter.FlushAsync(CancellationToken cancellationToken) + { + if (_root is not null) + { + var sb = new StringBuilder(); + SerializeTable(sb, _root, []); + await Writer.WriteAsync(sb.ToString()).ConfigureAwait(false); + } +#if NET8_OR_GREATER + await Writer.FlushAsync(cancellationToken).ConfigureAwait(false); +#else + await Writer.FlushAsync().ConfigureAwait(false); +#endif + } + + private void AddToParent(object node) + { + if (_nodeStack.Count == 0) + { + return; + } + + var parent = _nodeStack.Peek(); + if (parent is TomlTable table) + { + if (_pendingKey is null) + { + throw new OpenApiWriterException("A property name must be written before writing a value."); + } + table.Add(_pendingKey, node); + _pendingKey = null; + } + else if (parent is TomlArray array) + { + array.Items.Add(node); + } + } + + // ── TOML serialization ─────────────────────────────────────────────── + + /// + /// Recursively serialises a TOML table. + /// Scalars are emitted first, then arrays of tables, then sub-tables. + /// This ordering satisfies TOML's constraint that scalars of a section + /// must precede any child table/array-of-tables sections. + /// + private static void SerializeTable(StringBuilder sb, TomlTable table, List path) + { + // 1. Scalar key = value pairs (strings, numbers, booleans, inline arrays). + foreach (var key in table.Keys) + { + var value = table.Entries[key]; + if (!IsInlineValue(value)) + { + continue; + } + sb.Append(EscapeKey(key)); + sb.Append(" = "); + AppendInlineValue(sb, value); + sb.AppendLine(); + } + + // 2. Arrays of tables – [[path.key]] header per element. + foreach (var key in table.Keys) + { + var value = table.Entries[key]; + if (value is not TomlArray arr || !IsTableArray(arr)) + { + continue; + } + var newPath = new List(path) { key }; + var pathStr = string.Join(".", newPath.Select(EscapeKey)); + foreach (TomlTable item in arr.Items.Cast()) + { + sb.AppendLine(); + sb.Append("[["); + sb.Append(pathStr); + sb.AppendLine("]]"); + SerializeTable(sb, item, newPath); + } + } + + // 3. Sub-tables – [path.key] header (only when the sub-table has + // direct scalar content; otherwise the header is implied by deeper paths). + foreach (var key in table.Keys) + { + var value = table.Entries[key]; + if (value is not TomlTable subTable) + { + continue; + } + var newPath = new List(path) { key }; + SerializeSubTable(sb, subTable, newPath); + } + } + + private static void SerializeSubTable(StringBuilder sb, TomlTable table, List path) + { + bool hasDirectContent = table.Entries.Values.Any(IsInlineValue); + if (hasDirectContent) + { + sb.AppendLine(); + sb.Append('['); + sb.Append(string.Join(".", path.Select(EscapeKey))); + sb.AppendLine("]"); + } + SerializeTable(sb, table, path); + } + + // ── Predicate helpers ──────────────────────────────────────────────── + + private static bool IsInlineValue(object value) => + value switch + { + TomlScalar => true, + TomlArray arr => !IsTableArray(arr), // empty or scalar arrays → inline + _ => false, // TomlNull, TomlTable → not inline + }; + + private static bool IsTableArray(TomlArray array) => + array.Items.Count > 0 && array.Items.All(i => i is TomlTable); + + // ── Value formatters ───────────────────────────────────────────────── + + private static void AppendInlineValue(StringBuilder sb, object value) + { + switch (value) + { + case TomlScalar scalar: + AppendScalar(sb, scalar); + break; + case TomlArray array: + AppendInlineArray(sb, array); + break; + case TomlTable table: + AppendInlineTable(sb, table); + break; + case TomlNull: + // Null is not representable in TOML; callers skip null values. + break; + } + } + + private static void AppendScalar(StringBuilder sb, TomlScalar scalar) + { + switch (scalar.Kind) + { + case TomlScalarKind.String: + AppendTomlString(sb, (string)scalar.Value); + break; + case TomlScalarKind.Integer: + sb.Append(Convert.ToInt64(scalar.Value, CultureInfo.InvariantCulture)); + break; + case TomlScalarKind.Float: + AppendTomlFloat(sb, Convert.ToDouble(scalar.Value, CultureInfo.InvariantCulture)); + break; + case TomlScalarKind.Boolean: + sb.Append((bool)scalar.Value ? "true" : "false"); + break; + case TomlScalarKind.Raw: + sb.Append((string)scalar.Value); + break; + } + } + + private static void AppendTomlFloat(StringBuilder sb, double value) + { + if (double.IsPositiveInfinity(value)) { sb.Append("inf"); return; } + if (double.IsNegativeInfinity(value)) { sb.Append("-inf"); return; } + if (double.IsNaN(value)) { sb.Append("nan"); return; } + + // Ensure the number is rendered in a way TOML parsers recognise as a float. + var str = value.ToString("G17", CultureInfo.InvariantCulture); + sb.Append(str); + if (!str.Contains('.') && !str.Contains('E') && !str.Contains('e') + && !str.Equals("inf", StringComparison.Ordinal) + && !str.Equals("-inf", StringComparison.Ordinal) + && !str.Equals("nan", StringComparison.Ordinal)) + { + sb.Append(".0"); + } + } + + private static void AppendInlineArray(StringBuilder sb, TomlArray array) + { + sb.Append('['); + bool first = true; + foreach (var item in array.Items) + { + if (item is TomlNull) { continue; } + if (!first) { sb.Append(", "); } + AppendInlineValue(sb, item); + first = false; + } + sb.Append(']'); + } + + private static void AppendInlineTable(StringBuilder sb, TomlTable table) + { + sb.Append('{'); + bool first = true; + foreach (var key in table.Keys) + { + var val = table.Entries[key]; + if (val is TomlNull) { continue; } + if (!first) { sb.Append(", "); } + sb.Append(EscapeKey(key)); + sb.Append(" = "); + AppendInlineValue(sb, val); + first = false; + } + sb.Append('}'); + } + + // ── Key / string escaping ──────────────────────────────────────────── + + private static string EscapeKey(string key) => + BareKeyPattern.IsMatch(key) ? key : BuildTomlString(key); + + private static void AppendTomlString(StringBuilder sb, string value) + { + sb.Append(BuildTomlString(value)); + } + + private static string BuildTomlString(string value) + { + var sb = new StringBuilder("\""); + foreach (var c in value) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + default: + if (c < 0x20 || c == 0x7F) + { + sb.Append($"\\u{(int)c:X4}"); + } + else + { + sb.Append(c); + } + break; + } + } + sb.Append('"'); + return sb.ToString(); + } + + // ── Private TOML node model ────────────────────────────────────────── + + private enum TomlScalarKind + { + String, + Integer, + Float, + Boolean, + Raw, + } + + private sealed class TomlScalar + { + public TomlScalar(TomlScalarKind kind, object value) + { + Kind = kind; + Value = value; + } + + public TomlScalarKind Kind { get; } + public object Value { get; } + } + + private sealed class TomlNull + { + private TomlNull() { } + public static readonly TomlNull Instance = new(); + } + + private sealed class TomlArray + { + public List Items { get; } = new(); + } + + private sealed class TomlTable + { + private readonly Dictionary _entries = new(StringComparer.Ordinal); + + public IReadOnlyDictionary Entries => _entries; + + /// Insertion-ordered list of keys (preserves document order). + public List Keys { get; } = new(); + + public void Add(string key, object value) + { + if (!_entries.ContainsKey(key)) + { + Keys.Add(key); + } + _entries[key] = value; + } + } + } +} diff --git a/test/Microsoft.OpenApi.Tests/Writers/OpenApiTomlWriterTests.cs b/test/Microsoft.OpenApi.Tests/Writers/OpenApiTomlWriterTests.cs new file mode 100644 index 000000000..3a330df5e --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Writers/OpenApiTomlWriterTests.cs @@ -0,0 +1,374 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Writers +{ + [Collection("DefaultSettings")] + public class OpenApiTomlWriterTests + { + private static async Task SerializeAsync(Action act) + { + var outputString = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiTomlWriter(outputString); + act(writer); + await ((IOpenApiWriter)writer).FlushAsync(); + return outputString.GetStringBuilder().ToString(); + } + + [Fact] + public async Task WriteStringProperty_ShouldProduceQuotedTomlValue() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("title"); + w.WriteValue("My API"); + w.WriteEndObject(); + }); + + Assert.Contains("title = \"My API\"", toml); + } + + [Fact] + public async Task WriteIntProperty_ShouldProduceTomlInteger() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("port"); + w.WriteValue(8080); + w.WriteEndObject(); + }); + + Assert.Contains("port = 8080", toml); + } + + [Fact] + public async Task WriteBoolProperty_ShouldProduceLowercaseBool() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("required"); + w.WriteValue(true); + w.WriteEndObject(); + }); + + Assert.Contains("required = true", toml); + } + + [Fact] + public async Task WriteDecimalProperty_ShouldProduceTomlFloat() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("ratio"); + w.WriteValue(3.14m); + w.WriteEndObject(); + }); + + Assert.Contains("ratio = ", toml); + Assert.Contains("3.14", toml); + } + + [Fact] + public async Task WriteNullValue_ShouldBeOmittedFromOutput() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("missing"); + w.WriteNull(); + w.WritePropertyName("present"); + w.WriteValue("here"); + w.WriteEndObject(); + }); + + Assert.DoesNotContain("missing", toml); + Assert.Contains("present = \"here\"", toml); + } + + [Fact] + public async Task WriteStringWithSpecialChars_ShouldEscapeCorrectly() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("msg"); + w.WriteValue("line1\nline2\ttabbed \"quoted\""); + w.WriteEndObject(); + }); + + Assert.Contains(@"\n", toml); + Assert.Contains(@"\t", toml); + Assert.Contains(@"\""", toml); + } + + [Fact] + public async Task KeyWithSpecialChars_ShouldBeQuoted() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("/pets"); + w.WriteValue("path"); + w.WriteEndObject(); + }); + + Assert.Contains("\"/pets\" = \"path\"", toml); + } + + [Fact] + public async Task BareKey_ShouldNotBeQuoted() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("openapi"); + w.WriteValue("3.0.1"); + w.WriteEndObject(); + }); + + // The key must appear unquoted + Assert.Matches(new Regex(@"^openapi\s*=", RegexOptions.Multiline), toml); + } + + [Fact] + public async Task WriteStringArray_ShouldProduceTomlInlineArray() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("tags"); + w.WriteStartArray(); + w.WriteValue("pets"); + w.WriteValue("store"); + w.WriteEndArray(); + w.WriteEndObject(); + }); + + Assert.Contains("tags = [\"pets\", \"store\"]", toml); + } + + [Fact] + public async Task WriteEmptyArray_ShouldProduceEmptyInlineArray() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("tags"); + w.WriteStartArray(); + w.WriteEndArray(); + w.WriteEndObject(); + }); + + Assert.Contains("tags = []", toml); + } + + [Fact] + public async Task WriteArrayOfObjects_ShouldProduceArrayOfTableHeaders() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("servers"); + w.WriteStartArray(); + + w.WriteStartObject(); + w.WritePropertyName("url"); + w.WriteValue("https://example.com"); + w.WriteEndObject(); + + w.WriteStartObject(); + w.WritePropertyName("url"); + w.WriteValue("https://staging.example.com"); + w.WriteEndObject(); + + w.WriteEndArray(); + w.WriteEndObject(); + }); + + Assert.Contains("[[servers]]", toml); + Assert.Contains("url = \"https://example.com\"", toml); + Assert.Contains("url = \"https://staging.example.com\"", toml); + // Should appear exactly twice + Assert.Equal(2, CountOccurrences(toml, "[[servers]]")); + } + + [Fact] + public async Task WriteNestedObject_ShouldProduceSectionHeader() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("info"); + w.WriteStartObject(); + w.WritePropertyName("title"); + w.WriteValue("Sample API"); + w.WritePropertyName("version"); + w.WriteValue("1.0.0"); + w.WriteEndObject(); + w.WriteEndObject(); + }); + + Assert.Contains("[info]", toml); + Assert.Contains("title = \"Sample API\"", toml); + Assert.Contains("version = \"1.0.0\"", toml); + } + + [Fact] + public async Task WriteDeepNestedObject_ShouldProduceDottedSectionHeader() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("paths"); + w.WriteStartObject(); + w.WritePropertyName("/pets"); + w.WriteStartObject(); + w.WritePropertyName("get"); + w.WriteStartObject(); + w.WritePropertyName("summary"); + w.WriteValue("List pets"); + w.WriteEndObject(); + w.WriteEndObject(); + w.WriteEndObject(); + w.WriteEndObject(); + }); + + Assert.Contains("[paths.\"/pets\".get]", toml); + Assert.Contains("summary = \"List pets\"", toml); + } + + [Fact] + public async Task WriteRootScalar_ShouldAppearWithoutSectionHeader() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + w.WritePropertyName("openapi"); + w.WriteValue("3.0.1"); + w.WriteEndObject(); + }); + + // Root scalars must not be under a [header] + var lines = toml.Split('\n'); + var openapiLine = Array.FindIndex(lines, l => l.Contains("openapi = ")); + Assert.True(openapiLine >= 0); + + // No [section] header before the openapi line + for (var i = 0; i < openapiLine; i++) + { + Assert.DoesNotMatch(new Regex(@"^\["), lines[i].TrimStart()); + } + } + + [Fact] + public async Task WriteMinimalOpenApiDocument_ShouldProduceValidToml() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + + w.WritePropertyName("openapi"); + w.WriteValue("3.0.1"); + + w.WritePropertyName("info"); + w.WriteStartObject(); + w.WritePropertyName("title"); + w.WriteValue("Minimal API"); + w.WritePropertyName("version"); + w.WriteValue("0.1.0"); + w.WriteEndObject(); + + w.WritePropertyName("paths"); + w.WriteStartObject(); + w.WriteEndObject(); + + w.WriteEndObject(); + }); + + Assert.Contains("openapi = \"3.0.1\"", toml); + Assert.Contains("[info]", toml); + Assert.Contains("title = \"Minimal API\"", toml); + Assert.Contains("version = \"0.1.0\"", toml); + } + + [Fact] + public async Task WriteDocumentWithParameters_ShouldUseArrayOfTableHeaders() + { + var toml = await SerializeAsync(w => + { + w.WriteStartObject(); + + w.WritePropertyName("paths"); + w.WriteStartObject(); + w.WritePropertyName("/pets"); + w.WriteStartObject(); + w.WritePropertyName("get"); + w.WriteStartObject(); + + w.WritePropertyName("summary"); + w.WriteValue("List pets"); + + w.WritePropertyName("parameters"); + w.WriteStartArray(); + + w.WriteStartObject(); + w.WritePropertyName("name"); + w.WriteValue("limit"); + w.WritePropertyName("in"); + w.WriteValue("query"); + w.WriteEndObject(); + + w.WriteEndArray(); + + w.WriteEndObject(); // get + w.WriteEndObject(); // /pets + w.WriteEndObject(); // paths + w.WriteEndObject(); // root + }); + + Assert.Contains("[paths.\"/pets\".get]", toml); + Assert.Contains("summary = \"List pets\"", toml); + Assert.Contains("[[paths.\"/pets\".get.parameters]]", toml); + Assert.Contains("name = \"limit\"", toml); + Assert.Contains("in = \"query\"", toml); + } + + [Fact] + public async Task SerializeAsTomlAsync_Stream_ShouldProduceTomlOutput() + { + var document = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0" }, + }; + + var toml = await document.SerializeAsTomlAsync(OpenApiSpecVersion.OpenApi3_0); + + Assert.Contains("title = \"Test\"", toml); + Assert.Contains("[info]", toml); + } + + private static int CountOccurrences(string source, string pattern) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += pattern.Length; + } + return count; + } + } +}