tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/SwaggerTokenSerializer.cs (489 lines of code) (raw):

using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using SwaggerApiParser.SwaggerApiView; namespace SwaggerApiParser { public class SwaggerTokenSerializer { internal const string LanguageServiceName = "Swagger"; public string Name => LanguageServiceName; // This is an unfortunate hack because JsonLanguageService is already // squatting on `.json`. We'll have to fix this before we ask anyone // to use ApiView for swagger files. public string Extension => ".swagger"; // I don't really know what this is doing, but the other language // services do the same. It'd probably be worth making this the default // implementation if everyone needs to copy it as-is. public bool CanUpdate(string versionString) => false; public async Task<CodeFile> GetCodeFileInternalAsync(string originalName, Stream stream, bool runAnalysis) => SwaggerVisitor.GenerateCodeListing(originalName, await JsonDocument.ParseAsync(stream)); #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously public async Task<CodeFile> GetCodeFileFromJsonDocumentAsync(string originalName, JsonDocument jsonDoc, bool runAnalysis) => SwaggerVisitor.GenerateCodeListing(originalName, jsonDoc); #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously /// <summary> /// Generate an ApiView listing for an OpenAPI 2.0 specification in /// JSON. /// </summary> /// <param name="originalName">The name of the swagger file.</param> /// <param name="stream">Stream full of JSON.</param> /// <param name="runAnalysis">This is unused.</param> /// <returns>An ApiView listing.</returns> public async Task<CodeFile> GetCodeFileAsync(string originalName, Stream stream, bool runAnalysis) => await CodeFile.DeserializeAsync(stream); /// <summary> /// Incredibly simple class to make fenceposting (i.e., performing an /// operation between every element in a sequence but not before or /// after) easy and consistent. /// </summary> internal class Fenceposter { /// <summary> /// Flag indicating whether a separator is required. It starts /// false, but will be permanently flipped to true after the first /// time the RequiresSeperator property is accessed. /// </summary> private bool _requiresSeparator = false; /// <summary> /// Gets a value indicating whether a separator is required. This /// will always be false the first time it is called after a new /// Fenceposter is constructed and true every time after. /// </summary> public bool RequiresSeparator { get { if (_requiresSeparator) { return true; } else { _requiresSeparator = true; return false; } } } } /// <summary> /// IndentWriter provides helpful features for writing blocks of indented /// text (like source code, JSON, etc.). /// </summary> public partial class IndentWriter { /// <summary> /// The buffer where tokens are written. It is obtained by the /// user via IndentWriter.ToTokens(). /// </summary> private IList<CodeFileToken> _tokens = new List<CodeFileToken>(); /// <summary> /// Whether or not the last character written was a newline /// character (which means the next line written should /// automatically add the current indent depth). /// </summary> private bool _isNewline = true; /// <summary> /// Gets or sets the text used as each indent character (i.e., /// could be a single tab character or four space characters). The /// default value is four space characters. /// </summary> public string IndentText { get; set; } = " "; /// <summary> /// Gets the depth of the current indent level. /// </summary> public uint Indent { get; private set; } /// <summary> /// Gets the text that has been written thus far. /// </summary> /// <returns>The text written thus far.</returns> public CodeFileToken[] ToTokens() => _tokens.ToArray(); private int GetLastTokenIndex(CodeFileTokenKind? kind = null) { CodeFileToken last; for (int i = _tokens.Count - 1; i >= 0; i--) { last = _tokens[i]; if (kind == null || last.Kind == kind) { return i; } } return -1; } public void AnnotateDefinition(string definitionId) { int index = GetLastTokenIndex(); var last = _tokens[index]; last.DefinitionId = definitionId; _tokens[index] = last; } public void AnnotateLink(string navigationId, CodeFileTokenKind kind) { int index = GetLastTokenIndex(kind); var last = _tokens[index]; last.NavigateToId = navigationId; _tokens[index] = last; } /// <summary> /// Pushes the scope a level deeper. /// </summary> public void PushScope() => Indent++; /// <summary> /// Pops the scope a level. /// </summary> public void PopScope() { if (Indent == 0) { throw new InvalidOperationException("Cannot pop scope any further!"); } Indent--; } private void Append(CodeFileTokenKind kind, string text) => _tokens.Add(new CodeFileToken(text, kind)); /// <summary> /// Writes an indent if needed. This is used before each write /// operation to ensure we're always indenting. We don't need to /// indent for a series of calls like Write("Foo"); Write("Bar"); /// but would indent between a series like WriteLine("Foo"); /// Write("Bar"); /// </summary> private void WriteIndentIfNeeded() { // If we had just written a full line if (_isNewline) { _isNewline = false; // Then we'll write out the current indent depth before anything // else is written Append( CodeFileTokenKind.Whitespace, string.Concat(Enumerable.Repeat(IndentText, (int)Indent))); } } /// <summary> /// Write the text representation of the given values with /// indentation as appropriate. /// </summary> /// <param name='format'>Format string.</param> /// <param name='args'>Optional arguments to the format string.</param> public void Write(CodeFileTokenKind kind, string format, params object[] args) { WriteIndentIfNeeded(); // Only use AppendFormat if we have args so that we don't have // to escape curly brace literals used on their own. if (args?.Length > 0) { format = string.Format(format, args); } Append(kind, format); } /// <summary> /// Write the text representation of the given values followed by a /// newline, with indentation as appropriate. This will force the /// next Write call to indent before anything else is written. /// </summary> /// <param name='format'>Format string.</param> /// <param name='args'>Optional arguments to the format string.</param> public void WriteLine(CodeFileTokenKind kind, string format, params object[] args) { Write(kind, format, args); Write(CodeFileTokenKind.Newline, Environment.NewLine); // Track that we just wrote a line so the next write operation // will indent first _isNewline = true; } /// <summary> /// Write a newline (which will force the next write operation to /// indent before anything else is written). /// </summary> public void WriteLine() => WriteLine(CodeFileTokenKind.Text, null); /// <summary> /// Increase the indent level after writing the text representation /// of the given values to the current line. This would be used /// like: /// myIndentWriter.PushScope("{"); /// /* Write indented lines here */ /// myIndentWriter.PopScope("}"); /// </summary> /// <param name='format'>Format string.</param> /// <param name='args'>Optional arguments to the format string.</param> public void PushScope(CodeFileTokenKind kind, string format, params object[] args) => PushScope(newline: true, kind, format, args); public void PushScope(bool newline, CodeFileTokenKind kind, string format, params object[] args) { if (newline) { WriteLine(kind, format, args); } else { Write(kind, format, args); } PushScope(); } /// <summary> /// Decrease the indent level after writing the text representation /// of the given values to the current line. This would be used /// like: /// myIndentWriter.PushScope("{"); /// /* Write indented lines here */ /// myIndentWriter.PopScope("}"); /// </summary> /// <param name='format'>Format string.</param> /// <param name='args'>Optional arguments to the format string.</param> public void PopScope(CodeFileTokenKind kind, string format, params object[] args) => PopScope(newline: true, kind, format, args); public void PopScope(bool newline, CodeFileTokenKind kind, string format, params object[] args) { PopScope(); if (!string.IsNullOrEmpty(format)) { // Force the format string to be written on a new line, but // don't add an extra one if we just wrote a newline. if (!_isNewline && newline) { WriteLine(); } Write(kind, format, args); } } /// <summary> /// Create a writer scope that will indent until the scope is /// disposed. This is used like: /// using (writer.Scope()) /// { /// /* Write indented lines here */ /// } /// /* Back to normal here */ /// </summary> public IDisposable Scope() => new WriterScope(this); /// <summary> /// Create a writer scope that will indent until the scope is /// disposed and starts/ends the scope with the given text. This /// is used like: /// using (writer.Scope("{", "}")) /// { /// /* Write indented lines here */ /// } /// /* Back to normal here */ /// </summary> /// <param name='start'>Text starting the scope.</param> /// <param name='end'>Text ending the scope.</param> /// <param name="kind">The kind of token to use.</param> public IDisposable Scope(string start, string end, bool newline = true, CodeFileTokenKind kind = CodeFileTokenKind.Punctuation) => new WriterScope( this, start ?? throw new ArgumentNullException("start"), end ?? throw new ArgumentNullException("end"), newline, kind); /// <summary> /// The WriterScope class allows us to create an indentation block /// via a C# using statement. It will typically be used via /// something like: /// using (writer.Scope("{", "}")) /// { /// /* Indented writing here */ /// } /// /* No longer indented from here on... */ /// </summary> private class WriterScope : IDisposable { /// <summary> /// The IndentWriter that contains this scope. /// </summary> private IndentWriter _writer; /// <summary> /// An optional string to write upon closing the scope. /// </summary> private string _scopeEnd; /// <summary> /// An optional value indicating whether to add newlines. /// </summary> private bool _newline; /// <summary> /// Optional kind of token to write. /// </summary> private CodeFileTokenKind _kind; /// <summary> /// Initializes a new instance of the WriterScope class. /// </summary> /// <param name='writer'> /// The IndentWriter containing the scope. /// </param> /// <param name="kind">The kind of token to write.</param> public WriterScope(IndentWriter writer) { Debug.Assert(writer != null, "writer cannot be null!"); _writer = writer; _writer.PushScope(); } /// <summary> /// Initializes a new instance of the WriterScope class. /// </summary> /// <param name='writer'> /// The IndentWriter containing the scope. /// </param> /// <param name='scopeStart'>Text starting the scope.</param> /// <param name='scopeEnd'>Text ending the scope.</param> /// <param name="newline">Whether to inject a newline.</param> /// <param name="kind">The kind of token to write.</param> public WriterScope(IndentWriter writer, string scopeStart, string scopeEnd, bool newline, CodeFileTokenKind kind) { Debug.Assert(writer != null, "writer cannot be null!"); Debug.Assert(scopeStart != null, "scopeStart cannot be null!"); Debug.Assert(scopeEnd != null, "scopeEnd cannot be null!"); _writer = writer; _writer.PushScope(newline, kind, scopeStart); _scopeEnd = scopeEnd; _kind = kind; _newline = newline; } /// <summary> /// Close the scope. /// </summary> public void Dispose() { if (_writer != null) { // Close the scope with the desired text if given if (_scopeEnd != null) { _writer.PopScope(_newline, _kind, _scopeEnd); } else { _writer.PopScope(); } // Prevent multiple disposals _writer = null; } } } } /// <summary> /// Represents the navigation tree for a swagger document. /// </summary> internal class SwaggerTree { /// <summary> /// Gets or sets the display text of the tree. /// </summary> public string Text { get; set; } public string LongText { get; set; } /// <summary> /// Gets or sets the ID of the document element to navigate to. /// </summary> public string NavigationId { get; set; } /// <summary> /// Gets or sets the children of the current node. /// </summary> public IDictionary<string, SwaggerTree> Children { get; } = new Dictionary<string, SwaggerTree>(); /// <summary> /// Gets a value indicating whether this node is the root. /// </summary> public bool IsRoot => Parent == null; /// <summary> /// Gets a value indicating whether this node is a top-level entry. /// </summary> public bool IsTopLevel => Parent?.IsRoot == true && Text switch { "paths" => true, "x-ms-paths" => true, "parameters" => true, "definitions" => true, "responses" => true, _ => false }; /// <summary> /// Gets the parent of the current node. /// </summary> public SwaggerTree Parent { get; private set; } /// <summary> /// Gets a value indicating whether this node is a path entry. /// </summary> public bool IsPath => Parent?.Parent?.IsRoot == true && (Parent.Text == "paths" || Parent.Text == "x-ms-paths"); public bool IsResponses => Text switch { "responses" => true, _ => false }; /// <summary> /// Gets a value indicating whether this node's children should be /// added to the navigation tree. /// </summary> public bool HasNavigableChildren => IsRoot || IsTopLevel || IsPath || IsResponses; /// <summary> /// Add a child to the navigation tree. /// </summary> /// <param name="name">Display name of the child.</param> /// <param name="navigationId">Navigation ID of the child.</param> /// <param name="longText"></param> /// <returns>The child node.</returns> public SwaggerTree Add(string name, string navigationId, string longText = null) { if (!Children.TryGetValue(name, out SwaggerTree next)) { Children[name] = next = new SwaggerTree { Text = name, LongText = longText, NavigationId = navigationId, Parent = this }; } return next; } /// <summary> /// Turn the swagger view into an ApiView navigation item. /// </summary> /// <returns>An ApiView navigation item.</returns> private NavigationItem BuildItem() => new() { Text = Text, NavigationId = NavigationId, ChildItems = Children.Values.Select(c => c.BuildItem()).ToArray() }; /// <summary> /// Turn the swagger view into ApiView navigation items. /// </summary> /// <returns>ApiView navigation items.</returns> public NavigationItem[] Build() => BuildItem().ChildItems; } /// <summary> /// Visitor to generate ApiView listings for Swagger documents. /// </summary> internal class SwaggerVisitor { private IndentWriter _writer = new(); private SwaggerTree _nav = new(); private List<string> _path = new() { "#" }; public SwaggerVisitor() { } /// <summary> /// Generate the ApiView code listing for a swagger document. /// </summary> /// <param name="originalName">The name of the file.</param> /// <param name="document">The swagger document.</param> /// <returns>An ApiView CodeFile.</returns> public static CodeFile GenerateCodeListing(string originalName, JsonDocument document) { var navigationIdPrefix = $"{originalName}"; // Process the document SwaggerVisitor visitor = new(); visitor.Visit(document.RootElement, visitor._nav, navigationIdPrefix); // Ensure we're looking at OpenAPI 2.0 // if (GetString(document, "swagger") != "2.0") // { // throw new InvalidOperationException("Only Swagger 2.0 is supported."); // } // Pull the pieces together into a listing return new CodeFile() { Language = LanguageServiceName, Name = originalName, PackageName = GetString(document, "info", "title") ?? originalName, Tokens = visitor._writer.ToTokens(), Navigation = visitor._nav.Build() }; } public static CodeFileToken[] GenerateCodeFileTokens(JsonDocument document) { SwaggerVisitor visitor = new(); visitor.Visit(document.RootElement, visitor._nav); return visitor._writer.ToTokens(); } /// <summary> /// Generate the listing for a JSON value. /// </summary> /// <param name="element">The JSON value.</param> /// <param name="nav">Optional document navigation info.</param> /// <param name="navigationIdPrefix"></param> /// <param name="scopeStart"></param> /// <param name="scopeEnd"></param> private void Visit(JsonElement element, SwaggerTree nav = null, string navigationIdPrefix = "", string scopeStart = "{ ", string scopeEnd = " }") { switch (element.ValueKind) { case JsonValueKind.Object: VisitObject(element, nav, navigationIdPrefix, scopeStart, scopeEnd); break; case JsonValueKind.Array: VisitArray(element, nav, navigationIdPrefix); break; case JsonValueKind.False: case JsonValueKind.Null: case JsonValueKind.Number: case JsonValueKind.String: case JsonValueKind.True: case JsonValueKind.Undefined: VisitLiteral(element); break; default: break; } } /// <summary> /// Generate the listing for an object. /// </summary> /// <param name="obj">The JSON object.</param> /// <param name="nav">Optional document navigation info.</param> /// <param name="navigationIdPrefix"></param> /// <param name="scopeStart"></param> /// <param name="scopeEnd"></param> private void VisitObject(JsonElement obj, SwaggerTree nav, string navigationIdPrefix, string scopeStart = "{", string scopeEnd = "}") { bool multiLine = !FitsOnOneLine(obj); using (_writer.Scope(scopeStart, scopeEnd, multiLine)) { // Optionally sort the values IEnumerable<JsonProperty> values = obj.EnumerateObject(); if (nav?.IsRoot != true) { values = nav?.IsPath == true ? values.OrderBy(p => GetString(p.Value, "operationId") ?? p.Name, new OperationComparer()) : values.OrderBy(p => p.Name); } Fenceposter fencepost = new(); // Generate the listing for each property foreach (JsonProperty property in values) { Boolean IsCurObjCollapsible() { bool isPathScope = nav is { Text: "paths" or "x-ms-paths" }; bool isMethod = nav is { IsPath: true }; bool isDefinition = nav is { Text: "definitions" }; bool isMethodParameters = nav is { Parent: { IsPath: true } } && property.Name == "parameters"; bool isXmsExamples = nav is { Parent: { IsPath: true } } && property.Name == "x-ms-examples"; bool isResponses = nav is { Text: "responses" }; bool isParameters = nav is { Text: "parameters" }; bool isSecurityDefinitions = nav is { Text: "securityDefinitions" }; return isPathScope || isDefinition || isParameters || isSecurityDefinitions; } // Add the property to the current path _path.Add(property.Name); var isCollapsible = IsCurObjCollapsible(); // Write the property name _writer.Write(CodeFileTokenKind.Punctuation, "\""); var propertyType = isCollapsible ? CodeFileTokenKind.TypeName : CodeFileTokenKind.MemberName; _writer.Write(propertyType, property.Name); // Create an ID for this property var idPrefix = navigationIdPrefix.Length == 0 ? "" : $"{navigationIdPrefix}_"; string id = $"{idPrefix}{string.Join('-', _path).TrimStart('#')}"; _writer.AnnotateDefinition(id); if (isCollapsible) { _writer.Write(CodeFileTokenKind.FoldableSectionHeading, id); } // Optionally add a navigation tree node SwaggerTree next = null; if (nav?.HasNavigableChildren == true || (nav?.Parent.IsPath == true && property.Name is "responses" or "parameters")) { string name = property.Name; string longText = property.Name; if (nav.IsPath) { name = GetString(property.Value, "operationId") ?? name; } next = nav.Add(name, id, null); } // Visit the value if (isCollapsible) { _writer.Write(CodeFileTokenKind.Punctuation, "\": "); this._writer.WriteLine(); this._writer.Write(CodeFileTokenKind.FoldableSectionContentStart, null); Visit(property.Value, next, navigationIdPrefix); if (property.Name != values.Last().Name) { _writer.Write(CodeFileTokenKind.Punctuation, ", "); if (multiLine) { _writer.WriteLine(); } } this._writer.Write(CodeFileTokenKind.FoldableSectionContentEnd, null); } else { _writer.Write(CodeFileTokenKind.Punctuation, "\": "); Visit(property.Value, next, navigationIdPrefix); if (property.Name != values.Last().Name) { _writer.Write(CodeFileTokenKind.Punctuation, ", "); if (multiLine) { _writer.WriteLine(); } } } // Make $refs linked if (property.Name == "$ref" && property.Value.ValueKind == JsonValueKind.String && property.Value.GetString().StartsWith("#/")) // Ignore external docs { _writer.AnnotateLink($"{idPrefix}{property.Value.GetString().TrimStart('#').Replace('/', '-')}", CodeFileTokenKind.StringLiteral); } // Remove the property from the current path _path.RemoveAt(_path.Count - 1); } } } /// <summary> /// Generate the listing for an array. /// </summary> /// <param name="array">The array.</param> /// <param name="navigationIdPrefix"></param> /// <param name="scopeStart"></param> /// <param name="scopeEnd"></param> private void VisitArray(JsonElement array, SwaggerTree nav, string navigationIdPrefix, string scopeStart = "[ ", string scopeEnd = " ]") { bool multiLine = !FitsOnOneLine(array); using (_writer.Scope(scopeStart, scopeEnd, multiLine)) { int index = 0; Fenceposter fencepost = new(); Boolean IsCurObjCollapsible() { bool isParameters = nav is { Text: "parameters" }; return isParameters; } ; bool isCollapsible = IsCurObjCollapsible(); foreach (JsonElement child in array.EnumerateArray()) { if (fencepost.RequiresSeparator) { _writer.Write(CodeFileTokenKind.Punctuation, ", "); if (multiLine) { _writer.WriteLine(); } } _path.Add(index.ToString()); Visit(child, null, navigationIdPrefix); _path.RemoveAt(_path.Count - 1); index++; } } } /// <summary> /// Generate the listing for a literal value. /// </summary> /// <param name="value">The literal value.</param> private void VisitLiteral(JsonElement value) { switch (value.ValueKind) { case JsonValueKind.Null: _writer.Write(CodeFileTokenKind.Keyword, "null"); break; case JsonValueKind.Undefined: _writer.Write(CodeFileTokenKind.Keyword, "undefined"); break; case JsonValueKind.True: _writer.Write(CodeFileTokenKind.Keyword, "true"); break; case JsonValueKind.False: _writer.Write(CodeFileTokenKind.Keyword, "false"); break; case JsonValueKind.Number: _writer.Write(CodeFileTokenKind.Literal, value.GetDouble().ToString()); break; case JsonValueKind.String: _writer.Write(CodeFileTokenKind.Punctuation, "\""); _writer.Write(CodeFileTokenKind.StringLiteral, value.GetString()); _writer.Write(CodeFileTokenKind.Punctuation, "\""); break; default: throw new InvalidOperationException($"Expected a literal JSON element, not {value.ValueKind}."); } } /// <summary> /// Crude heuristic to determine whether a JsonElement can be /// rendered on a single line. /// </summary> /// <param name="element">The JSON to render.</param> /// <returns>Whether it fits on a single line.</returns> private bool FitsOnOneLine(JsonElement element) { const int maxObjectProperties = 2; const int maxArrayElements = 3; const int maxStringLength = 50; switch (element.ValueKind) { case JsonValueKind.Object: int properties = 0; foreach (JsonProperty property in element.EnumerateObject()) { if (property.Value.ValueKind == JsonValueKind.Array || property.Value.ValueKind == JsonValueKind.Object || !FitsOnOneLine(property.Value) || properties++ > maxObjectProperties) { return false; } } return true; case JsonValueKind.Array: int values = 0; foreach (JsonElement value in element.EnumerateArray()) { if (value.ValueKind == JsonValueKind.Array || value.ValueKind == JsonValueKind.Object || !FitsOnOneLine(value) || values++ > maxArrayElements) { return false; } } return true; case JsonValueKind.String: return element.GetString().Length <= maxStringLength; case JsonValueKind.False: case JsonValueKind.Null: case JsonValueKind.Number: case JsonValueKind.True: case JsonValueKind.Undefined: default: return true; } } /// <summary> /// Get a string in a JSON document. /// </summary> /// <param name="doc">The JSON document.</param> /// <param name="path">Path to the string.</param> /// <returns>The desired string, or null.</returns> private static string GetString(JsonDocument doc, params string[] path) => GetString(doc.RootElement, path); /// <summary> /// Get a string in a JSON document. /// </summary> /// <param name="element">The element to start at.</param> /// <param name="path">Path to the string.</param> /// <returns>The desired string, or null.</returns> private static string GetString(JsonElement element, params string[] path) { foreach (string part in path) { if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(part, out element)) { return null; } } return element.ValueKind == JsonValueKind.String ? element.GetString() : null; } /// <summary> /// Comparer to sort the operations within a path. It puts /// OperationIds before things like parameters. /// </summary> private class OperationComparer : IComparer<string> { /// <summary> /// Compare two path entry names. /// </summary> /// <param name="x">The first path entry name.</param> /// <param name="y">The second path entry name.</param> /// <returns>-1 if the first is smaller, 0 if equal, or 1 if the first is larger.</returns> public int Compare([AllowNull] string x, [AllowNull] string y) => (x?.Contains('_'), y?.Contains('_')) switch { (null, null) => 0, (null, _) => 1, (_, null) => -1, (true, false) => -1, (false, true) => 1, _ => string.Compare(x, y) }; } } } }