From f186690c0b6bcf59df869ce6c3f0ca78639e1645 Mon Sep 17 00:00:00 2001 From: Millicent Achieng Date: Tue, 16 Jan 2024 15:53:20 +0300 Subject: [PATCH 01/12] Add extension method to check string distance between two strings --- ApiDoctor.Validation/ExtensionMethods.cs | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/ApiDoctor.Validation/ExtensionMethods.cs b/ApiDoctor.Validation/ExtensionMethods.cs index a2e75e71..a8948728 100644 --- a/ApiDoctor.Validation/ExtensionMethods.cs +++ b/ApiDoctor.Validation/ExtensionMethods.cs @@ -574,7 +574,43 @@ public static bool IsHeaderBlock(this Block block) return false; } } + /// + /// Uses the Damerau-Levenshtein distance algorithm which calculates how different one string is from another + /// + /// + /// + /// + public static int StringDistance(this string s, string t) + { + var bounds = new { Height = s.Length + 1, Width = t.Length + 1 }; + + int[,] matrix = new int[bounds.Height, bounds.Width]; + + for (int height = 0; height < bounds.Height; height++) { matrix[height, 0] = height; }; + for (int width = 0; width < bounds.Width; width++) { matrix[0, width] = width; }; + + for (int height = 1; height < bounds.Height; height++) + { + for (int width = 1; width < bounds.Width; width++) + { + int cost = (s[height - 1] == t[width - 1]) ? 0 : 1; + int insertion = matrix[height, width - 1] + 1; + int deletion = matrix[height - 1, width] + 1; + int substitution = matrix[height - 1, width - 1] + cost; + int distance = Math.Min(insertion, Math.Min(deletion, substitution)); + + if (height > 1 && width > 1 && s[height - 1] == t[width - 2] && s[height - 2] == t[width - 1]) + { + distance = Math.Min(distance, matrix[height - 2, width - 2] + cost); + } + + matrix[height, width] = distance; + } + } + + return matrix[bounds.Height - 1, bounds.Width - 1]; + } public static void SplitUrlComponents(this string inputUrl, out string path, out string queryString) { From a11c9c3ccfbdbd6e2a08fe1aed0c26d2310f8ca3 Mon Sep 17 00:00:00 2001 From: Millicent Achieng Date: Tue, 16 Jan 2024 15:58:24 +0300 Subject: [PATCH 02/12] Add extension method for parsing JSON --- ApiDoctor.Validation/ExtensionMethods.cs | 100 +++++++++++++---------- 1 file changed, 59 insertions(+), 41 deletions(-) diff --git a/ApiDoctor.Validation/ExtensionMethods.cs b/ApiDoctor.Validation/ExtensionMethods.cs index a8948728..c68ff904 100644 --- a/ApiDoctor.Validation/ExtensionMethods.cs +++ b/ApiDoctor.Validation/ExtensionMethods.cs @@ -1,42 +1,42 @@ /* - * API Doctor - * Copyright (c) Microsoft Corporation - * All rights reserved. - * - * MIT License - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the ""Software""), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, - * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A - * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ +* API Doctor +* Copyright (c) Microsoft Corporation +* All rights reserved. +* +* MIT License +* +* Permission is hereby granted, free of charge, to any person obtaining a copy of +* this software and associated documentation files (the ""Software""), to deal in +* the Software without restriction, including without limitation the rights to use, +* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +* Software, and to permit persons to whom the Software is furnished to do so, +* subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ namespace ApiDoctor.Validation { using System; using System.Collections.Generic; using System.Diagnostics; + using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; - using ApiDoctor.Validation.Error; - using ApiDoctor.Validation.Json; using MarkdownDeep; + using Newtonsoft.Json; using Newtonsoft.Json.Linq; - using System.Globalization; + using ApiDoctor.Validation.Error; using ApiDoctor.Validation.OData.Transformation; public static class ExtensionMethods @@ -50,9 +50,9 @@ public static class ExtensionMethods private static readonly Regex markdownLinkRegex = new(@"\[(.*?)\]((\(.*?\))|(\s*:\s*\w+))", RegexOptions.Compiled); private static readonly Regex markdownBoldRegex = new(@"\*\*(.*?)\*\*", RegexOptions.Compiled); - + private static readonly Regex markdownItalicRegex = new(@"_(.*?)_", RegexOptions.Compiled); - + private static readonly Regex markdownCodeRegex = new(@"`(.*?)`", RegexOptions.Compiled); private static readonly string[] Iso8601Formats = @@ -194,7 +194,7 @@ public static void IntersectInPlace(this IList source, IList otherSet) } } - public static bool TryGetPropertyValue(this JContainer container, string propertyName, out T value ) where T : class + public static bool TryGetPropertyValue(this JContainer container, string propertyName, out T value) where T : class { JProperty prop; if (TryGetProperty(container, propertyName, out prop)) @@ -238,14 +238,14 @@ public static string RemoveMarkdownStyling(this string markdownText) markdownText = markdownBoldRegex.Replace(markdownText, "$1"); // Remove italic (_text_) - markdownText = markdownItalicRegex .Replace(markdownText, "$1"); + markdownText = markdownItalicRegex.Replace(markdownText, "$1"); // Remove code (`code`) - markdownText = markdownCodeRegex .Replace(markdownText, "$1"); + markdownText = markdownCodeRegex.Replace(markdownText, "$1"); // Remove links [text](link_target) or [text]: link_reference markdownText = markdownLinkRegex.Replace(markdownText, "$1"); - + return markdownText; } @@ -308,7 +308,7 @@ public static string ValueForColumn(this string[] rowValues, IMarkdownTable tabl } } - Debug.WriteLine("Failed to find header matching '{0}' in table with headers: {1}", + Debug.WriteLine("Failed to find header matching '{0}' in table with headers: {1}", possibleHeaderNames.ComponentsJoinedByString(","), table.ColumnHeaders.ComponentsJoinedByString(",")); return null; @@ -330,7 +330,7 @@ public static int IndexOf(this string[] array, string value, StringComparison co /// /// /// - public static ParameterDataType ParseParameterDataType(this string value, bool isCollection = false, Action addErrorAction = null, ParameterDataType defaultValue = null ) + public static ParameterDataType ParseParameterDataType(this string value, bool isCollection = false, Action addErrorAction = null, ParameterDataType defaultValue = null) { const string collectionPrefix = "collection("; const string collectionOfPrefix = "collection of"; @@ -360,7 +360,7 @@ public static ParameterDataType ParseParameterDataType(this string value, bool i // Value could have markdown formatting in it, so we do some basic work to try and remove that if it exists if (value.IndexOf('[') != -1) { - value = value.TextBetweenCharacters('[', ']'); + value = value.TextBetweenCharacters('[', ']'); } SimpleDataType simpleType = ParseSimpleTypeString(value.ToLowerInvariant()); @@ -682,11 +682,11 @@ public static string TextBetweenCharacters(this string source, char first, char if (startIndex == -1) return source; - int endIndex = source.IndexOf(second, startIndex+1); + int endIndex = source.IndexOf(second, startIndex + 1); if (endIndex == -1) - return source.Substring(startIndex+1); + return source.Substring(startIndex + 1); else - return source.Substring(startIndex+1, endIndex - startIndex - 1); + return source.Substring(startIndex + 1, endIndex - startIndex - 1); } public static string TextBetweenCharacters(this string source, char character) @@ -707,7 +707,7 @@ public static string ReplaceTextBetweenCharacters( char first, char second, string replacement, - bool requireSecondChar = true, + bool requireSecondChar = true, bool removeTargetChars = false) { StringBuilder output = new StringBuilder(source); @@ -737,7 +737,7 @@ public static string ReplaceTextBetweenCharacters( output.Remove(i + 1, j - i - (foundLastChar ? 1 : 0)); output.Insert(i + 1, replacement); } - + i += replacement.Length; } } @@ -751,7 +751,7 @@ internal static ExpectedStringFormat StringFormat(this ParameterDefinition param if (param.Type != ParameterDataType.String) return ExpectedStringFormat.Generic; - if (param.OriginalValue == "timestamp" || param.OriginalValue == "datetime" || param.OriginalValue.Contains("timestamp") ) + if (param.OriginalValue == "timestamp" || param.OriginalValue == "datetime" || param.OriginalValue.Contains("timestamp")) return ExpectedStringFormat.Iso8601Date; if (param.OriginalValue == "url" || param.OriginalValue == "absolute url") return ExpectedStringFormat.AbsoluteUrl; @@ -828,6 +828,24 @@ public static bool IsLikelyBase64Encoded(this string input) return null; } + public static bool TryParseJson(this string obj, out T result) + { + try + { + var settings = new JsonSerializerSettings + { + MissingMemberHandling = MissingMemberHandling.Error + }; + result = JsonConvert.DeserializeObject(obj, settings); + return true; + } + catch (Exception) + { + result = default; + return false; + } + } + /// /// Checks to see if a collection of errors already includes a similar error (matching Code + Message string) /// From 159e0f8e82982a07c85b9598132ccaf250d63b1c Mon Sep 17 00:00:00 2001 From: Millicent Achieng Date: Tue, 16 Jan 2024 16:02:09 +0300 Subject: [PATCH 03/12] Add new validation error codes --- ApiDoctor.Validation/Error/validationerror.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ApiDoctor.Validation/Error/validationerror.cs b/ApiDoctor.Validation/Error/validationerror.cs index 032190e1..5bdb3ed5 100644 --- a/ApiDoctor.Validation/Error/validationerror.cs +++ b/ApiDoctor.Validation/Error/validationerror.cs @@ -113,6 +113,8 @@ public enum ValidationErrorCode ExtraDocumentHeaderFound, RequiredDocumentHeaderMissing, DocumentHeaderInWrongPosition, + DocumentHeaderInWrongCase, + MisspeltDocumentHeader, RequiredYamlHeaderMissing, IncorrectYamlHeaderFormat, SkippedSimilarErrors, From 2491d04be578e5fae86d5ce0a50d8718b5baf67f Mon Sep 17 00:00:00 2001 From: Millicent Achieng Date: Tue, 16 Jan 2024 16:06:20 +0300 Subject: [PATCH 04/12] Configure fetching of document structure JSON config files --- ApiDoctor.Console/Program.cs | 2 +- ApiDoctor.Validation/Config/ConfigFile.cs | 2 +- .../Config/DocumentOutlineFile.cs | 252 +++++++++++++++--- ApiDoctor.Validation/DocSet.cs | 6 +- 4 files changed, 222 insertions(+), 40 deletions(-) diff --git a/ApiDoctor.Console/Program.cs b/ApiDoctor.Console/Program.cs index e4d823aa..7f721b41 100644 --- a/ApiDoctor.Console/Program.cs +++ b/ApiDoctor.Console/Program.cs @@ -332,7 +332,7 @@ private static Task GetDocSetAsync(DocSetOptions options, IssueLogger is DateTimeOffset end = DateTimeOffset.Now; TimeSpan duration = end.Subtract(start); - FancyConsole.WriteLine($"Took {duration.TotalSeconds} to parse {set.Files.Length} source files."); + FancyConsole.WriteLine($"Took {duration.TotalSeconds}s to parse {set.Files.Length} source files."); return Task.FromResult(set); } diff --git a/ApiDoctor.Validation/Config/ConfigFile.cs b/ApiDoctor.Validation/Config/ConfigFile.cs index 7d7880c9..fa7089a5 100644 --- a/ApiDoctor.Validation/Config/ConfigFile.cs +++ b/ApiDoctor.Validation/Config/ConfigFile.cs @@ -31,7 +31,7 @@ public abstract class ConfigFile public string SourcePath { get; set; } /// - /// Provide oppertunity to post-process a valid configuration after the file is loaded. + /// Provide opportunity to post-process a valid configuration after the file is loaded. /// public virtual void LoadComplete() { diff --git a/ApiDoctor.Validation/Config/DocumentOutlineFile.cs b/ApiDoctor.Validation/Config/DocumentOutlineFile.cs index b239bd7c..139bc147 100644 --- a/ApiDoctor.Validation/Config/DocumentOutlineFile.cs +++ b/ApiDoctor.Validation/Config/DocumentOutlineFile.cs @@ -1,45 +1,58 @@ /* - * API Doctor - * Copyright (c) Microsoft Corporation - * All rights reserved. - * - * MIT License - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the ""Software""), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, - * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A - * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ +* API Doctor +* Copyright (c) Microsoft Corporation +* All rights reserved. +* +* MIT License +* +* Permission is hereby granted, free of charge, to any person obtaining a copy of +* this software and associated documentation files (the ""Software""), to deal in +* the Software without restriction, including without limitation the rights to use, +* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +* Software, and to permit persons to whom the Software is furnished to do so, +* subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ namespace ApiDoctor.Validation.Config { using System; + using System.Linq; using System.Collections.Generic; using Newtonsoft.Json; + using Newtonsoft.Json.Linq; public class DocumentOutlineFile : ConfigFile { - [JsonProperty("allowedDocumentHeaders")] - public DocumentHeader[] AllowedHeaders { get; set; } + [JsonProperty("apiPageType"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public List ApiPageType { get; set; } - public override bool IsValid + [JsonProperty("resourcePageType"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public List ResourcePageType { get; set; } + + [JsonProperty("conceptualPageType"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public List ConceptualPageType { get; set; } + + [JsonProperty("enumPageType"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public List EnumPageType { get; set; } + + public override bool IsValid => this.ApiPageType.Any() || this.ResourcePageType.Any() || this.ConceptualPageType.Any() || this.EnumPageType.Any(); + + public DocumentOutlineFile() { - get - { - return this.AllowedHeaders != null; - } + ApiPageType = new List(); + ResourcePageType = new List(); + ConceptualPageType = new List(); + EnumPageType = new List(); } } @@ -75,19 +88,184 @@ public DocumentHeader() [JsonProperty("headers")] public List ChildHeaders { get; set; } - internal bool Matches(DocumentHeader found) + internal bool Matches(DocumentHeader found, bool ignoreCase = false, bool checkStringDistance = false) { - return this.Level == found.Level && DoTitlesMatch(this.Title, found.Title); + if (checkStringDistance) + { + return IsMisspelt(found); + } + + return this.Level == found.Level && DoTitlesMatch(this.Title, found.Title, ignoreCase); } - private static bool DoTitlesMatch(string expectedTitle, string foundTitle) + private static bool DoTitlesMatch(string expectedTitle, string foundTitle, bool ignoreCase) { - if (expectedTitle == foundTitle) return true; + StringComparison comparisonType = StringComparison.Ordinal; + if (ignoreCase) comparisonType = StringComparison.OrdinalIgnoreCase; + + if (expectedTitle.Equals(foundTitle, comparisonType)) + return true; if (string.IsNullOrEmpty(expectedTitle) || expectedTitle == "*") return true; - if (expectedTitle.StartsWith("* ") && foundTitle.EndsWith(expectedTitle.Substring(2))) return true; - if (expectedTitle.EndsWith(" *") && foundTitle.StartsWith(expectedTitle.Substring(0, expectedTitle.Length - 2))) return true; + if (expectedTitle.StartsWith("* ") && foundTitle.EndsWith(expectedTitle.Substring(2), comparisonType)) return true; + if (expectedTitle.EndsWith(" *") && foundTitle.StartsWith(expectedTitle.Substring(0, expectedTitle.Length - 2), comparisonType)) return true; return false; } + internal bool IsMisspelt(DocumentHeader found) + { + return this.Level == found.Level && this.Title.StringDistance(found.Title) < 5; + } + + public override string ToString() + { + return this.Title; + } + } + + public class ExpectedHeader : DocumentHeader + { + public ExpectedHeader() + { + Level = 1; + ChildHeaders = new List(); + } + + /// + /// Indicates that a header pattern can be repeated multiple times e.g. in the case of multiple examples + /// + [JsonProperty("multiple")] + public bool Multiple { get; set; } + + /// + /// Specifies the headers that are allowed under this header. + /// + [JsonProperty("headers"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public new List ChildHeaders { get; set; } + + public ExpectedHeader Clone() + { + var header = (ExpectedHeader)this.MemberwiseClone(); + List childHeaders = new List(); + foreach (var childHeader in this.ChildHeaders) + { + if (childHeader is ExpectedHeader) + { + childHeaders.Add(((ExpectedHeader)childHeader).Clone()); + continue; + } + childHeaders.Add(((ConditionalHeader)childHeader).Clone()); + } + header.ChildHeaders = childHeaders; + return header; + } + } + + public class ConditionalHeader + { + [JsonProperty("condition")] + public string Condition { get; set; } + + [JsonProperty("arguments"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public List Arguments { get; set; } + + public ConditionalOperator? Operator + { + get + { + ConditionalOperator op; + return Enum.TryParse(this.Condition, true, out op) ? op : (ConditionalOperator?)null; + } + } + + public ConditionalHeader Clone() + { + var header = (ConditionalHeader)this.MemberwiseClone(); + List arguments = new List(); + foreach (var arg in this.Arguments) + { + if (arg is ExpectedHeader) + { + arguments.Add(((ExpectedHeader)arg).Clone()); + continue; + } + arguments.Add(((ConditionalHeader)arg).Clone()); + } + header.Arguments = arguments; + return header; + } } -} + public enum ConditionalOperator + { + OR, + AND + } + + public class DocumentHeaderJsonConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + serializer.Serialize(writer, value); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.StartArray) + { + var jArray = JArray.Load(reader); + var expectedHeaders = new List(); + foreach (var item in jArray) + { + bool isConditionalHeader = item.ToString().TryParseJson(out ConditionalHeader conditionalHeader); + if (isConditionalHeader) + { + expectedHeaders.Add(conditionalHeader); + continue; + } + + bool isExpectedHeader = item.ToString().TryParseJson(out ExpectedHeader header); + if (isExpectedHeader) + { + expectedHeaders.Add(header); + continue; + } + + // Object is neither of type ExpectedHeader nor ConditionalHeader + throw new JsonReaderException("Invalid document header definition"); + } + return expectedHeaders; + } + else if (reader.TokenType == JsonToken.StartObject) + { + var jObject = JObject.Load(reader); + + bool isConditionalHeader = jObject.ToString().TryParseJson(out ConditionalHeader conditionalHeader); + if (isConditionalHeader) + { + return conditionalHeader; + } + + bool isExpectedHeader = jObject.ToString().TryParseJson(out ExpectedHeader header); + if (isExpectedHeader) + { + return header; + } + + // Object is neither of type ExpectedHeader nor ConditionalHeader + throw new JsonReaderException($"Invalid document header definition: {jObject.ToString()}"); + } + else if (reader.TokenType == JsonToken.Null) + { + return null; + } + else + { + throw new JsonSerializationException($"Unexpected token: {existingValue}"); + } + } + + public override bool CanConvert(Type objectType) + { + return false; + } + } +} \ No newline at end of file diff --git a/ApiDoctor.Validation/DocSet.cs b/ApiDoctor.Validation/DocSet.cs index 664b07f5..6bea9b45 100644 --- a/ApiDoctor.Validation/DocSet.cs +++ b/ApiDoctor.Validation/DocSet.cs @@ -97,7 +97,7 @@ public IEnumerable ErrorCodes public MetadataValidationConfigs MetadataValidationConfigs { get; internal set; } - public DocumentOutlineFile DocumentStructure { get; internal set; } + public DocumentOutlineFile DocumentStructure { get; internal set; } = new DocumentOutlineFile(); public LinkValidationConfigFile LinkValidationConfig { get; private set; } @@ -161,6 +161,10 @@ private void LoadRequirements() Console.WriteLine("Using document structure file: {0}", foundOutlines.SourcePath); this.DocumentStructure = foundOutlines; } + else + { + Console.WriteLine("Document structure file has been incorrectly formatted or is missing"); + } LinkValidationConfigFile[] linkConfigs = TryLoadConfigurationFiles(this.SourceFolderPath); var foundLinkConfig = linkConfigs.FirstOrDefault(); From 45159bb4351eb5e0e886d0ec606b85fed1430495 Mon Sep 17 00:00:00 2001 From: Millicent Achieng Date: Tue, 16 Jan 2024 17:09:39 +0300 Subject: [PATCH 05/12] Validate docs structure against config based on doc type --- .../Config/DocumentOutlineFile.cs | 55 +-- ApiDoctor.Validation/DocFile.cs | 367 +++++++++++++----- 2 files changed, 308 insertions(+), 114 deletions(-) diff --git a/ApiDoctor.Validation/Config/DocumentOutlineFile.cs b/ApiDoctor.Validation/Config/DocumentOutlineFile.cs index 139bc147..97e6fc95 100644 --- a/ApiDoctor.Validation/Config/DocumentOutlineFile.cs +++ b/ApiDoctor.Validation/Config/DocumentOutlineFile.cs @@ -91,9 +91,7 @@ public DocumentHeader() internal bool Matches(DocumentHeader found, bool ignoreCase = false, bool checkStringDistance = false) { if (checkStringDistance) - { return IsMisspelt(found); - } return this.Level == found.Level && DoTitlesMatch(this.Title, found.Title, ignoreCase); } @@ -101,13 +99,21 @@ internal bool Matches(DocumentHeader found, bool ignoreCase = false, bool checkS private static bool DoTitlesMatch(string expectedTitle, string foundTitle, bool ignoreCase) { StringComparison comparisonType = StringComparison.Ordinal; - if (ignoreCase) comparisonType = StringComparison.OrdinalIgnoreCase; + if (ignoreCase) + comparisonType = StringComparison.OrdinalIgnoreCase; if (expectedTitle.Equals(foundTitle, comparisonType)) return true; - if (string.IsNullOrEmpty(expectedTitle) || expectedTitle == "*") return true; - if (expectedTitle.StartsWith("* ") && foundTitle.EndsWith(expectedTitle.Substring(2), comparisonType)) return true; - if (expectedTitle.EndsWith(" *") && foundTitle.StartsWith(expectedTitle.Substring(0, expectedTitle.Length - 2), comparisonType)) return true; + + if (string.IsNullOrEmpty(expectedTitle) || expectedTitle == "*") + return true; + + if (expectedTitle.StartsWith("* ") && foundTitle.EndsWith(expectedTitle.Substring(2), comparisonType)) + return true; + + if (expectedTitle.EndsWith(" *") && foundTitle.StartsWith(expectedTitle[..^2], comparisonType)) + return true; + return false; } internal bool IsMisspelt(DocumentHeader found) @@ -143,16 +149,14 @@ public ExpectedHeader() public ExpectedHeader Clone() { - var header = (ExpectedHeader)this.MemberwiseClone(); - List childHeaders = new List(); + var header = this.MemberwiseClone() as ExpectedHeader; + var childHeaders = new List(); foreach (var childHeader in this.ChildHeaders) { - if (childHeader is ExpectedHeader) - { - childHeaders.Add(((ExpectedHeader)childHeader).Clone()); - continue; - } - childHeaders.Add(((ConditionalHeader)childHeader).Clone()); + if (childHeader is ExpectedHeader expectedChildHeader) + childHeaders.Add(expectedChildHeader.Clone()); + else + childHeaders.Add(((ConditionalHeader)childHeader).Clone()); } header.ChildHeaders = childHeaders; return header; @@ -171,23 +175,20 @@ public ConditionalOperator? Operator { get { - ConditionalOperator op; - return Enum.TryParse(this.Condition, true, out op) ? op : (ConditionalOperator?)null; + return Enum.TryParse(this.Condition, true, out ConditionalOperator op) ? op : null; } } public ConditionalHeader Clone() { var header = (ConditionalHeader)this.MemberwiseClone(); - List arguments = new List(); + var arguments = new List(); foreach (var arg in this.Arguments) { - if (arg is ExpectedHeader) - { - arguments.Add(((ExpectedHeader)arg).Clone()); - continue; - } - arguments.Add(((ConditionalHeader)arg).Clone()); + if (arg is ExpectedHeader headerArg) + arguments.Add(headerArg.Clone()); + else + arguments.Add(((ConditionalHeader)arg).Clone()); } header.Arguments = arguments; return header; @@ -212,27 +213,27 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist if (reader.TokenType == JsonToken.StartArray) { var jArray = JArray.Load(reader); - var expectedHeaders = new List(); + var allowedHeaders = new List(); foreach (var item in jArray) { bool isConditionalHeader = item.ToString().TryParseJson(out ConditionalHeader conditionalHeader); if (isConditionalHeader) { - expectedHeaders.Add(conditionalHeader); + allowedHeaders.Add(conditionalHeader); continue; } bool isExpectedHeader = item.ToString().TryParseJson(out ExpectedHeader header); if (isExpectedHeader) { - expectedHeaders.Add(header); + allowedHeaders.Add(header); continue; } // Object is neither of type ExpectedHeader nor ConditionalHeader throw new JsonReaderException("Invalid document header definition"); } - return expectedHeaders; + return allowedHeaders; } else if (reader.TokenType == JsonToken.StartObject) { diff --git a/ApiDoctor.Validation/DocFile.cs b/ApiDoctor.Validation/DocFile.cs index b38ebc48..1f555a3f 100644 --- a/ApiDoctor.Validation/DocFile.cs +++ b/ApiDoctor.Validation/DocFile.cs @@ -1,27 +1,27 @@ /* - * API Doctor - * Copyright (c) Microsoft Corporation - * All rights reserved. - * - * MIT License - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the ""Software""), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, - * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A - * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ +* API Doctor +* Copyright (c) Microsoft Corporation +* All rights reserved. +* +* MIT License +* +* Permission is hereby granted, free of charge, to any person obtaining a copy of +* this software and associated documentation files (the ""Software""), to deal in +* the Software without restriction, including without limitation the rights to use, +* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +* Software, and to permit persons to whom the Software is furnished to do so, +* subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ namespace ApiDoctor.Validation { @@ -35,7 +35,7 @@ namespace ApiDoctor.Validation using Tags; using MarkdownDeep; using Newtonsoft.Json; - + using ApiDoctor.Validation.Config; /// /// A documentation file that may contain one more resources or API methods @@ -197,7 +197,7 @@ public string Namespace protected DocFile() { this.ContentOutline = new List(); - this.DocumentHeaders = new List(); + this.DocumentHeaders = new List(); } public DocFile(string basePath, string relativePath, DocSet parent) @@ -207,7 +207,7 @@ public DocFile(string basePath, string relativePath, DocSet parent) this.FullPath = Path.Combine(basePath, relativePath.Substring(1)); this.DisplayName = relativePath; this.Parent = parent; - this.DocumentHeaders = new List(); + this.DocumentHeaders = new List(); } #endregion @@ -488,7 +488,6 @@ private static bool IsHeaderBlock(Block block, int maxDepth = 2) return false; } - protected string PreviewOfBlockContent(Block block) { if (block == null) return string.Empty; @@ -503,9 +502,9 @@ protected string PreviewOfBlockContent(Block block) return contentPreview; } - protected Config.DocumentHeader CreateHeaderFromBlock(Block block) + protected DocumentHeader CreateHeaderFromBlock(Block block) { - var header = new Config.DocumentHeader(); + var header = new DocumentHeader(); switch (block.BlockType) { case BlockType.h1: @@ -530,7 +529,15 @@ protected Config.DocumentHeader CreateHeaderFromBlock(Block block) /// /// Headers found in the markdown input (#, h1, etc) /// - public List DocumentHeaders + public List DocumentHeaders + { + get; set; + } + + /// + /// Expected headers as defined in json config file + /// + public List AllowedDocumentHeaders { get; set; } @@ -551,7 +558,7 @@ protected bool ParseMarkdownBlocks(IssueLogger issues) List foundElements = new List(); - Stack headerStack = new Stack(); + Stack headerStack = new Stack(); for (int i = 0; i < this.OriginalMarkdownBlocks.Length; i++) { var block = this.OriginalMarkdownBlocks[i]; @@ -581,8 +588,7 @@ protected bool ParseMarkdownBlocks(IssueLogger issues) else if (previousHeaderBlock.BlockType == BlockType.h1) { methodDescriptionsData.Add(block.Content); - // make sure we omit the namespace description as well as the national cloud deployments paragraphs - methodDescription = string.Join(" ", methodDescriptionsData.Where(static x => !x.StartsWith("Namespace:",StringComparison.OrdinalIgnoreCase) && !x.Contains("[national cloud deployments](/graph/deployments)",StringComparison.OrdinalIgnoreCase))).ToStringClean(); + methodDescription = string.Join(" ", methodDescriptionsData.SkipWhile(x => x.StartsWith("Namespace:"))).ToStringClean(); issues.Message($"Found description: {methodDescription}"); } } @@ -689,99 +695,286 @@ protected bool ParseMarkdownBlocks(IssueLogger issues) return issues.Issues.All(x => !x.IsError); } + public static List CopyDocumentHeaders(List headers) + { + var newHeaders = new List(); + + foreach (var header in headers) + { + if (header is ExpectedHeader expectedHeader) + { + newHeaders.Add(expectedHeader.Clone()); + continue; + } + newHeaders.Add(((ConditionalHeader)header).Clone()); + } + return newHeaders; + } + + /// + /// Set expected headers for doc file based on the document type as specified in YAML metadata + /// + /// + public void SetExpectedDocumentHeaders() + { + var documentOutline = this.Parent.DocumentStructure; + switch (this.DocumentPageType) + { + case PageType.ApiPageType: + this.AllowedDocumentHeaders = CopyDocumentHeaders(documentOutline.ApiPageType); + break; + case PageType.ResourcePageType: + this.AllowedDocumentHeaders = CopyDocumentHeaders(documentOutline.ResourcePageType); + break; + case PageType.EnumPageType: + this.AllowedDocumentHeaders = CopyDocumentHeaders(documentOutline.EnumPageType); + break; + case PageType.ConceptualPageType: + this.AllowedDocumentHeaders = CopyDocumentHeaders(documentOutline.ConceptualPageType); + break; + default: + this.AllowedDocumentHeaders = new List(); + break; + } + } + + /// /// Checks the document for outline errors compared to any required document structure. /// /// public void CheckDocumentStructure(IssueLogger issues) { - List errors = new List(); - if (this.Parent.DocumentStructure != null) + SetExpectedDocumentHeaders(); + if (this.AllowedDocumentHeaders.Any()) { - ValidateDocumentHeaders(this.Parent.DocumentStructure.AllowedHeaders, this.DocumentHeaders, issues); + ValidateDocumentHeaders(this.AllowedDocumentHeaders, this.DocumentHeaders, issues); } ValidateTabStructure(issues); } - private static bool ContainsMatchingDocumentHeader(Config.DocumentHeader expectedHeader, IReadOnlyList collection) + private static bool ContainsMatchingDocumentHeader(DocumentHeader expectedHeader, IReadOnlyList collection, + bool ignoreCase = false, bool checkStringDistance = false) { - return collection.Any(h => h.Matches(expectedHeader)); + return collection.Any(h => h.Matches(expectedHeader, ignoreCase, checkStringDistance)); } - private void ValidateDocumentHeaders(IReadOnlyList expectedHeaders, IReadOnlyList foundHeaders, IssueLogger issues) + private void ValidateDocumentHeaders(List expectedHeaders, IReadOnlyList foundHeaders, IssueLogger issues) { int expectedIndex = 0; int foundIndex = 0; while (expectedIndex < expectedHeaders.Count && foundIndex < foundHeaders.Count) { - var expected = expectedHeaders[expectedIndex]; - var found = foundHeaders[foundIndex]; + DocumentHeaderValidationResult result = ValidateDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); - if (expected.Matches(found)) + var found = foundHeaders[foundIndex]; + var expected = expectedHeaders[expectedIndex] as ExpectedHeader; + switch (result) { - ValidateDocumentHeaders(expected.ChildHeaders, found.ChildHeaders, issues); + case DocumentHeaderValidationResult.Found: + ValidateDocumentHeaders(expected.ChildHeaders, found.ChildHeaders, issues); + foundIndex++; + //if expecting multiple headers of the same pattern, do not increment expected until last header matching pattern is found + if (!expected.Multiple || (expected.Multiple && foundIndex == foundHeaders.Count)) + expectedIndex++; + break; - // Found an expected header, keep going! - expectedIndex++; - foundIndex++; - continue; - } + case DocumentHeaderValidationResult.FoundInWrongCase: + issues.Error(ValidationErrorCode.DocumentHeaderInWrongCase, $"Incorrect letter case in document header: {found.Title}"); + expectedIndex++; + foundIndex++; + break; - if (!ContainsMatchingDocumentHeader(found, expectedHeaders)) - { - // This is an additional header that isn't in the expected header collection - issues.Warning(ValidationErrorCode.ExtraDocumentHeaderFound, $"A extra document header was found: {found.Title}"); - ValidateDocumentHeaders(new Config.DocumentHeader[0], found.ChildHeaders, issues); - foundIndex++; - continue; - } - else - { - // If the current expected header is optional, we can move past it - if (!expected.Required) - { + case DocumentHeaderValidationResult.MisspeltDocumentHeader: + issues.Error(ValidationErrorCode.MisspeltDocumentHeader, $"Found header: {found.Title}. Did you mean: {expected.Title}?"); expectedIndex++; - continue; - } + foundIndex++; + break; - bool expectedMatchesInFoundHeaders = ContainsMatchingDocumentHeader(expected, foundHeaders); - if (expectedMatchesInFoundHeaders) - { - // This header exists, but is in the wrong position + case DocumentHeaderValidationResult.MisspeltDocumentHeaderInWrongPosition: + issues.Error(ValidationErrorCode.MisspeltDocumentHeader, $"An expected document header (possibly misspelt) was found in the wrong position: {found.Title}"); + foundIndex++; + break; + + case DocumentHeaderValidationResult.ExtraDocumentHeaderFound: + issues.Warning(ValidationErrorCode.ExtraDocumentHeaderFound, $"An extra document header was found: {found.Title}"); + foundIndex++; + break; + + case DocumentHeaderValidationResult.DocumentHeaderInWrongPosition: issues.Warning(ValidationErrorCode.DocumentHeaderInWrongPosition, $"An expected document header was found in the wrong position: {found.Title}"); foundIndex++; - continue; - } - else if (!expectedMatchesInFoundHeaders && expected.Required) - { - // Missing a required header! + break; + + case DocumentHeaderValidationResult.RequiredDocumentHeaderMissing: issues.Error(ValidationErrorCode.RequiredDocumentHeaderMissing, $"A required document header is missing from the document: {expected.Title}"); expectedIndex++; - } - else - { - // Expected wasn't found and is optional, that's fine. + break; + + case DocumentHeaderValidationResult.OptionalDocumentHeaderMissing: expectedIndex++; - continue; - } + break; + + default: + break; + + } + + //if expecting multiple headers of the same pattern, increment expected when last header matching pattern is found + if (expected.Multiple && foundIndex == foundHeaders.Count) + { + expectedIndex++; } } for (int i = foundIndex; i < foundHeaders.Count; i++) { - issues.Warning(ValidationErrorCode.ExtraDocumentHeaderFound, $"A extra document header was found: {foundHeaders[i].Title}"); + issues.Warning(ValidationErrorCode.ExtraDocumentHeaderFound, $"An extra document header was found: {foundHeaders[i].Title}"); } + for (int i = expectedIndex; i < expectedHeaders.Count; i++) { - if (expectedHeaders[i].Required) + ExpectedHeader missingHeader; + if (expectedHeaders[i] is ExpectedHeader expectedMissingHeader) + { + missingHeader = expectedMissingHeader; + } + else { - issues.Error(ValidationErrorCode.RequiredDocumentHeaderMissing, $"A required document header is missing from the document: {expectedHeaders[i].Title}"); + missingHeader = (expectedHeaders[i] as ConditionalHeader).Arguments.OfType().First(); + } + + if (!ContainsMatchingDocumentHeader(missingHeader, foundHeaders, true, true) && missingHeader.Required) + { + issues.Error(ValidationErrorCode.RequiredDocumentHeaderMissing, $"A required document header is missing from the document: {missingHeader.Title}"); } } } - private void AddHeaderToHierarchy(Stack headerStack, Block block) + private DocumentHeaderValidationResult ValidateDocumentHeader(List expectedHeaders, IReadOnlyList foundHeaders, int expectedIndex, int foundIndex) + { + if (expectedHeaders[expectedIndex] is ConditionalHeader) + { + return ValidateConditionalDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); + } + + var found = foundHeaders[foundIndex]; + var expected = expectedHeaders[expectedIndex] as ExpectedHeader; + + if (expected.Matches(found)) + { + return DocumentHeaderValidationResult.Found; + } + + // Try case insensitive match + if (expected.Matches(found, true)) + { + return DocumentHeaderValidationResult.FoundInWrongCase; + } + + // Check if header is misspelt + if (expected.IsMisspelt(found)) + { + return DocumentHeaderValidationResult.MisspeltDocumentHeader; + } + + var mergedExpectedHeaders = ExtractAndMergeConditionalHeaderArgumentsInList(expectedHeaders); + // Expected doesn't match found, check if found is in wrong position or is extra header + if (!ContainsMatchingDocumentHeader(found, mergedExpectedHeaders, true)) + { + // Check if header has been misspelt and is in wrong position + if (ContainsMatchingDocumentHeader(found, mergedExpectedHeaders, true, true)) + { + return DocumentHeaderValidationResult.MisspeltDocumentHeaderInWrongPosition; + } + + // This is an additional header that isn't in the expected header collection + return DocumentHeaderValidationResult.ExtraDocumentHeaderFound; + } + else + { + bool expectedMatchesInFoundHeaders = ContainsMatchingDocumentHeader(expected, foundHeaders, true, true); + if (expectedMatchesInFoundHeaders) + { + // This header exists, but is in the wrong position + return DocumentHeaderValidationResult.DocumentHeaderInWrongPosition; + } + else if (!expectedMatchesInFoundHeaders && expected.Required) + { + // Missing a required header! + return DocumentHeaderValidationResult.RequiredDocumentHeaderMissing; + } + else + { + // Expected wasn't found and is optional + return DocumentHeaderValidationResult.OptionalDocumentHeaderMissing; + } + } + } + + private DocumentHeaderValidationResult ValidateConditionalDocumentHeader(List expectedHeaders, IReadOnlyList foundHeaders, int expectedIndex, int foundIndex) + { + var validationResult = DocumentHeaderValidationResult.None; + var expectedConditionalHeader = expectedHeaders[expectedIndex] as ConditionalHeader; + if (expectedConditionalHeader.Operator == ConditionalOperator.OR) + { + foreach (var header in expectedConditionalHeader.Arguments) + { + // Replace conditional header with this argument for validation + expectedHeaders[expectedIndex] = header; + validationResult = ValidateDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); + + // If header has been found, stop looking + if (validationResult == DocumentHeaderValidationResult.Found || + validationResult == DocumentHeaderValidationResult.FoundInWrongCase) + { + break; + } + } + } + else if (expectedConditionalHeader.Operator == ConditionalOperator.AND) + { + expectedHeaders[expectedIndex] = expectedConditionalHeader.Arguments.First(); + expectedHeaders.InsertRange(expectedIndex + 1, expectedConditionalHeader.Arguments.Skip(1)); + + validationResult = ValidateDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); + } + return validationResult; + } + + private List ExtractAndMergeConditionalHeaderArgumentsInList(IReadOnlyList expectedHeaders) + { + List allHeaders = new List(); + foreach (var header in expectedHeaders) + { + if (header is ExpectedHeader) + { + allHeaders.Add((ExpectedHeader)header); + } + else if (header is ConditionalHeader) + { + var arguments = ExtractAndMergeConditionalHeaderArgumentsInList(((ConditionalHeader)header).Arguments); + allHeaders.Concat(arguments); + } + } + return allHeaders; + } + + private enum DocumentHeaderValidationResult + { + None, + Found, + FoundInWrongCase, + ExtraDocumentHeaderFound, + RequiredDocumentHeaderMissing, + OptionalDocumentHeaderMissing, + DocumentHeaderInWrongPosition, + MisspeltDocumentHeader, + MisspeltDocumentHeaderInWrongPosition + } + + private void AddHeaderToHierarchy(Stack headerStack, Block block) { var header = CreateHeaderFromBlock(block); if (header.Level == 1 || headerStack.Count == 0) @@ -856,7 +1049,7 @@ private void ValidateTabStructure(IssueLogger issues) } if (currentLine.Contains("# Example", StringComparison.OrdinalIgnoreCase)) - currentState = TabDetectionState.FindStartOfTabGroup; + currentState = TabDetectionState.FindStartOfTabGroup; break; case TabDetectionState.FindStartOfTabGroup: if (isTabHeader) @@ -866,7 +1059,7 @@ private void ValidateTabStructure(IssueLogger issues) if (foundTabIndex == 0 && !currentLine.Contains("#tab/http")) issues.Error(ValidationErrorCode.TabHeaderError, $"The first tab should be 'HTTP' in tab group #{foundTabGroups}"); - + if (foundTabGroups == 1) tabHeaders.Add(currentLine); @@ -915,7 +1108,7 @@ private void ValidateTabStructure(IssueLogger issues) } } - + if (currentState == TabDetectionState.FindEndOfTabGroup) issues.Error(ValidationErrorCode.TabHeaderError, $"Missing tab boundary in document for tab group #{foundTabGroups}"); } @@ -1480,7 +1673,7 @@ public List ParseCodeBlock(Block metadata, Block code, IssueLogg MethodDefinition pairedRequest = (from m in this.requests where m.Identifier == requestMethodName select m).FirstOrDefault(); if (pairedRequest != null) { - try + try { pairedRequest.AddExpectedResponse(GetBlockContent(code), annotation); responses.Add(pairedRequest); @@ -1821,4 +2014,4 @@ public override bool Equals(object obj) } } -} +} \ No newline at end of file From da542968842864d23e6a127ed3fee48d670ae4ed Mon Sep 17 00:00:00 2001 From: Millicent Achieng Date: Fri, 19 Jan 2024 02:49:09 +0300 Subject: [PATCH 06/12] Use Fastenshtein library to check string distance --- .../ApiDoctor.Validation.csproj | 1 + .../Config/DocumentOutlineFile.cs | 3 +- ApiDoctor.Validation/ExtensionMethods.cs | 37 ------------------- 3 files changed, 3 insertions(+), 38 deletions(-) diff --git a/ApiDoctor.Validation/ApiDoctor.Validation.csproj b/ApiDoctor.Validation/ApiDoctor.Validation.csproj index 2c90af6f..5b3f4717 100644 --- a/ApiDoctor.Validation/ApiDoctor.Validation.csproj +++ b/ApiDoctor.Validation/ApiDoctor.Validation.csproj @@ -23,6 +23,7 @@ + diff --git a/ApiDoctor.Validation/Config/DocumentOutlineFile.cs b/ApiDoctor.Validation/Config/DocumentOutlineFile.cs index 97e6fc95..f4f6b06c 100644 --- a/ApiDoctor.Validation/Config/DocumentOutlineFile.cs +++ b/ApiDoctor.Validation/Config/DocumentOutlineFile.cs @@ -30,6 +30,7 @@ namespace ApiDoctor.Validation.Config using System.Collections.Generic; using Newtonsoft.Json; using Newtonsoft.Json.Linq; + using Fastenshtein; public class DocumentOutlineFile : ConfigFile { @@ -118,7 +119,7 @@ private static bool DoTitlesMatch(string expectedTitle, string foundTitle, bool } internal bool IsMisspelt(DocumentHeader found) { - return this.Level == found.Level && this.Title.StringDistance(found.Title) < 5; + return this.Level == found.Level && Levenshtein.Distance(this.Title, found.Title) < 3; } public override string ToString() diff --git a/ApiDoctor.Validation/ExtensionMethods.cs b/ApiDoctor.Validation/ExtensionMethods.cs index c68ff904..b0c2afa5 100644 --- a/ApiDoctor.Validation/ExtensionMethods.cs +++ b/ApiDoctor.Validation/ExtensionMethods.cs @@ -574,43 +574,6 @@ public static bool IsHeaderBlock(this Block block) return false; } } - /// - /// Uses the Damerau-Levenshtein distance algorithm which calculates how different one string is from another - /// - /// - /// - /// - public static int StringDistance(this string s, string t) - { - var bounds = new { Height = s.Length + 1, Width = t.Length + 1 }; - - int[,] matrix = new int[bounds.Height, bounds.Width]; - - for (int height = 0; height < bounds.Height; height++) { matrix[height, 0] = height; }; - for (int width = 0; width < bounds.Width; width++) { matrix[0, width] = width; }; - - for (int height = 1; height < bounds.Height; height++) - { - for (int width = 1; width < bounds.Width; width++) - { - int cost = (s[height - 1] == t[width - 1]) ? 0 : 1; - int insertion = matrix[height, width - 1] + 1; - int deletion = matrix[height - 1, width] + 1; - int substitution = matrix[height - 1, width - 1] + cost; - - int distance = Math.Min(insertion, Math.Min(deletion, substitution)); - - if (height > 1 && width > 1 && s[height - 1] == t[width - 2] && s[height - 2] == t[width - 1]) - { - distance = Math.Min(distance, matrix[height - 2, width - 2] + cost); - } - - matrix[height, width] = distance; - } - } - - return matrix[bounds.Height - 1, bounds.Width - 1]; - } public static void SplitUrlComponents(this string inputUrl, out string path, out string queryString) { From 1ad5e4d49413131eb13b29622543d5912ab0ca51 Mon Sep 17 00:00:00 2001 From: Millicent Achieng Date: Mon, 11 Mar 2024 16:03:58 +0300 Subject: [PATCH 07/12] Optimize code for validating doc structure --- .vscode/launch.json | 31 ++- .../Config/DocumentOutlineFile.cs | 206 +++++------------- ApiDoctor.Validation/DocFile.cs | 199 ++++++++--------- ApiDoctor.Validation/DocSet.cs | 4 +- ApiDoctor.Validation/DocumentHeader.cs | 91 ++++++++ .../TableSpec/tablespecconverter.cs | 4 +- 6 files changed, 259 insertions(+), 276 deletions(-) create mode 100644 ApiDoctor.Validation/DocumentHeader.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index 88bd5ca2..5fe48713 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,16 +11,27 @@ "preLaunchTask": "build", "program": "${workspaceFolder}/ApiDoctor.Console/bin/Debug/net8.0/apidoc.dll", "args": [ - "generate-snippets", - "--ignore-warnings", - "--path", - "/home/codespace/workspace/microsoft-graph-docs", - "--snippet-generator-path", - "/home/codespace/workspace/microsoft-graph-explorer-api/CodeSnippetsReflection.App/bin/Debug/net8.0/CodeSnippetsReflection.App", - "--lang", - "Java", - "--git-path", - "/bin/git" + // "generate-snippets", + // "generate-permission-files", + // "--bootstrapping-only", + "check-all", + "--ignore-warnings", + "--path", + // "C:/Repos/test-docs/api-reference/alpha", + // "C:/Repos/test-docs/api-reference/delta", + // "C:/Repos/microsoft-graph-docs/api-reference/v1.0", + "C:/Repos/microsoft-graph-docs/api-reference/beta", + "--log", + "C:/Logs/api-doctor/Logs.txt", + // "--permissions-source-file", + // "https://raw.githubusercontent.com/microsoftgraph/microsoft-graph-devx-content/dev/permissions/new/permissions.json" + // "C:/Users/miachien/Downloads/GraphPermissions.json" + // "--snippet-generator-path", + // "C:/Repos/microsoft-graph-devx-api/CodeSnippetsReflection.App/bin/Debug/net7.0/CodeSnippetsReflection.App.exe", + // "--lang", + // "C#,Java,JavaScript", + // "--git-path", + // "/bin/git" ], "cwd": "${workspaceFolder}/ApiDoctor.Console", "console": "internalConsole", diff --git a/ApiDoctor.Validation/Config/DocumentOutlineFile.cs b/ApiDoctor.Validation/Config/DocumentOutlineFile.cs index f4f6b06c..9863c05a 100644 --- a/ApiDoctor.Validation/Config/DocumentOutlineFile.cs +++ b/ApiDoctor.Validation/Config/DocumentOutlineFile.cs @@ -25,146 +25,75 @@ namespace ApiDoctor.Validation.Config { - using System; - using System.Linq; - using System.Collections.Generic; using Newtonsoft.Json; using Newtonsoft.Json.Linq; - using Fastenshtein; + using System; + using System.Collections.Generic; public class DocumentOutlineFile : ConfigFile { [JsonProperty("apiPageType"), JsonConverter(typeof(DocumentHeaderJsonConverter))] - public List ApiPageType { get; set; } + public List ApiPageType { get; set; } = []; [JsonProperty("resourcePageType"), JsonConverter(typeof(DocumentHeaderJsonConverter))] - public List ResourcePageType { get; set; } + public List ResourcePageType { get; set; } = []; [JsonProperty("conceptualPageType"), JsonConverter(typeof(DocumentHeaderJsonConverter))] - public List ConceptualPageType { get; set; } + public List ConceptualPageType { get; set; } = []; [JsonProperty("enumPageType"), JsonConverter(typeof(DocumentHeaderJsonConverter))] - public List EnumPageType { get; set; } - - public override bool IsValid => this.ApiPageType.Any() || this.ResourcePageType.Any() || this.ConceptualPageType.Any() || this.EnumPageType.Any(); + public List EnumPageType { get; set; } = []; - public DocumentOutlineFile() - { - ApiPageType = new List(); - ResourcePageType = new List(); - ConceptualPageType = new List(); - EnumPageType = new List(); - } + public override bool IsValid => ApiPageType.Count > 0 || ResourcePageType.Count > 0 || ConceptualPageType.Count > 0 || EnumPageType.Count > 0; } - public class DocumentHeader - { - public DocumentHeader() - { - Level = 1; - ChildHeaders = new List(); - } - - /// - /// Represents the header level using markdown formatting (1=#, 2=##, 3=###, 4=####, 5=#####, 6=######) - /// - [JsonProperty("level")] - public int Level { get; set; } - - /// - /// Indicates that a header at this level is required. - /// - [JsonProperty("required")] - public bool Required { get; set; } + public class ExpectedDocumentHeader : DocumentHeader + { /// - /// The expected value of a title or empty to indicate any value + /// Indicates that a header pattern can be repeated multiple times e.g. in the case of multiple examples /// - [JsonProperty("title")] - public string Title { get; set; } + [JsonProperty("allowMultiple")] + public bool AllowMultiple { get; set; } /// /// Specifies the headers that are allowed under this header. /// - [JsonProperty("headers")] - public List ChildHeaders { get; set; } + [JsonProperty("headers"), JsonConverter(typeof(DocumentHeaderJsonConverter))] + public new List ChildHeaders { get; set; } = []; - internal bool Matches(DocumentHeader found, bool ignoreCase = false, bool checkStringDistance = false) - { - if (checkStringDistance) - return IsMisspelt(found); + public ExpectedDocumentHeader() { } - return this.Level == found.Level && DoTitlesMatch(this.Title, found.Title, ignoreCase); - } - - private static bool DoTitlesMatch(string expectedTitle, string foundTitle, bool ignoreCase) + public ExpectedDocumentHeader(ExpectedDocumentHeader original) : base(original) { - StringComparison comparisonType = StringComparison.Ordinal; - if (ignoreCase) - comparisonType = StringComparison.OrdinalIgnoreCase; - - if (expectedTitle.Equals(foundTitle, comparisonType)) - return true; + if (original == null) + return; - if (string.IsNullOrEmpty(expectedTitle) || expectedTitle == "*") - return true; - - if (expectedTitle.StartsWith("* ") && foundTitle.EndsWith(expectedTitle.Substring(2), comparisonType)) - return true; - - if (expectedTitle.EndsWith(" *") && foundTitle.StartsWith(expectedTitle[..^2], comparisonType)) - return true; - - return false; - } - internal bool IsMisspelt(DocumentHeader found) - { - return this.Level == found.Level && Levenshtein.Distance(this.Title, found.Title) < 3; - } + AllowMultiple = original.AllowMultiple; - public override string ToString() - { - return this.Title; + ChildHeaders = CopyHeaders(original.ChildHeaders); } - } - public class ExpectedHeader : DocumentHeader - { - public ExpectedHeader() + public static List CopyHeaders(List headers) { - Level = 1; - ChildHeaders = new List(); - } - - /// - /// Indicates that a header pattern can be repeated multiple times e.g. in the case of multiple examples - /// - [JsonProperty("multiple")] - public bool Multiple { get; set; } - - /// - /// Specifies the headers that are allowed under this header. - /// - [JsonProperty("headers"), JsonConverter(typeof(DocumentHeaderJsonConverter))] - public new List ChildHeaders { get; set; } + if (headers == null) + return null; - public ExpectedHeader Clone() - { - var header = this.MemberwiseClone() as ExpectedHeader; - var childHeaders = new List(); - foreach (var childHeader in this.ChildHeaders) + var headersCopy = new List(); + foreach (var header in headers) { - if (childHeader is ExpectedHeader expectedChildHeader) - childHeaders.Add(expectedChildHeader.Clone()); - else - childHeaders.Add(((ConditionalHeader)childHeader).Clone()); + headersCopy.Add(header switch + { + ConditionalDocumentHeader conditionalDocHeader => new ConditionalDocumentHeader(conditionalDocHeader), + ExpectedDocumentHeader expectedDocHeader => new ExpectedDocumentHeader(expectedDocHeader), + _ => throw new InvalidOperationException("Unexpected header type") + }); } - header.ChildHeaders = childHeaders; - return header; + return headersCopy; } } - public class ConditionalHeader + public class ConditionalDocumentHeader { [JsonProperty("condition")] public string Condition { get; set; } @@ -180,19 +109,14 @@ public ConditionalOperator? Operator } } - public ConditionalHeader Clone() + public ConditionalDocumentHeader(ConditionalDocumentHeader original) { - var header = (ConditionalHeader)this.MemberwiseClone(); - var arguments = new List(); - foreach (var arg in this.Arguments) - { - if (arg is ExpectedHeader headerArg) - arguments.Add(headerArg.Clone()); - else - arguments.Add(((ConditionalHeader)arg).Clone()); - } - header.Arguments = arguments; - return header; + if (original == null) + return; + + Condition = original.Condition; + + Arguments = ExpectedDocumentHeader.CopyHeaders(original.Arguments); } } @@ -204,57 +128,36 @@ public enum ConditionalOperator public class DocumentHeaderJsonConverter : JsonConverter { - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override bool CanConvert(Type objectType) { - serializer.Serialize(writer, value); + return objectType == typeof(ExpectedDocumentHeader) || objectType == typeof(ConditionalDocumentHeader); } public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { if (reader.TokenType == JsonToken.StartArray) { - var jArray = JArray.Load(reader); var allowedHeaders = new List(); + var jArray = JArray.Load(reader); foreach (var item in jArray) { - bool isConditionalHeader = item.ToString().TryParseJson(out ConditionalHeader conditionalHeader); - if (isConditionalHeader) + if (item["condition"] != null) { + var conditionalHeader = item.ToObject(serializer); allowedHeaders.Add(conditionalHeader); - continue; } - - bool isExpectedHeader = item.ToString().TryParseJson(out ExpectedHeader header); - if (isExpectedHeader) + else if (item["title"] != null) { - allowedHeaders.Add(header); - continue; + var expectedHeader = item.ToObject(serializer); + allowedHeaders.Add(expectedHeader); + } + else + { + throw new JsonReaderException("Invalid document header definition"); } - - // Object is neither of type ExpectedHeader nor ConditionalHeader - throw new JsonReaderException("Invalid document header definition"); } return allowedHeaders; } - else if (reader.TokenType == JsonToken.StartObject) - { - var jObject = JObject.Load(reader); - - bool isConditionalHeader = jObject.ToString().TryParseJson(out ConditionalHeader conditionalHeader); - if (isConditionalHeader) - { - return conditionalHeader; - } - - bool isExpectedHeader = jObject.ToString().TryParseJson(out ExpectedHeader header); - if (isExpectedHeader) - { - return header; - } - - // Object is neither of type ExpectedHeader nor ConditionalHeader - throw new JsonReaderException($"Invalid document header definition: {jObject.ToString()}"); - } else if (reader.TokenType == JsonToken.Null) { return null; @@ -265,9 +168,10 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist } } - public override bool CanConvert(Type objectType) + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { - return false; + serializer.Serialize(writer, value); } } + } \ No newline at end of file diff --git a/ApiDoctor.Validation/DocFile.cs b/ApiDoctor.Validation/DocFile.cs index 1f555a3f..0b403d41 100644 --- a/ApiDoctor.Validation/DocFile.cs +++ b/ApiDoctor.Validation/DocFile.cs @@ -502,7 +502,7 @@ protected string PreviewOfBlockContent(Block block) return contentPreview; } - protected DocumentHeader CreateHeaderFromBlock(Block block) + protected static DocumentHeader CreateHeaderFromBlock(Block block) { var header = new DocumentHeader(); switch (block.BlockType) @@ -534,14 +534,6 @@ public List DocumentHeaders get; set; } - /// - /// Expected headers as defined in json config file - /// - public List AllowedDocumentHeaders - { - get; set; - } - /// /// Convert blocks of text found inside the markdown file into things we know how to work /// with (methods, resources, examples, etc). @@ -695,89 +687,59 @@ protected bool ParseMarkdownBlocks(IssueLogger issues) return issues.Issues.All(x => !x.IsError); } - public static List CopyDocumentHeaders(List headers) - { - var newHeaders = new List(); - - foreach (var header in headers) - { - if (header is ExpectedHeader expectedHeader) - { - newHeaders.Add(expectedHeader.Clone()); - continue; - } - newHeaders.Add(((ConditionalHeader)header).Clone()); - } - return newHeaders; - } - - /// - /// Set expected headers for doc file based on the document type as specified in YAML metadata - /// - /// - public void SetExpectedDocumentHeaders() - { - var documentOutline = this.Parent.DocumentStructure; - switch (this.DocumentPageType) - { - case PageType.ApiPageType: - this.AllowedDocumentHeaders = CopyDocumentHeaders(documentOutline.ApiPageType); - break; - case PageType.ResourcePageType: - this.AllowedDocumentHeaders = CopyDocumentHeaders(documentOutline.ResourcePageType); - break; - case PageType.EnumPageType: - this.AllowedDocumentHeaders = CopyDocumentHeaders(documentOutline.EnumPageType); - break; - case PageType.ConceptualPageType: - this.AllowedDocumentHeaders = CopyDocumentHeaders(documentOutline.ConceptualPageType); - break; - default: - this.AllowedDocumentHeaders = new List(); - break; - } - } - - /// /// Checks the document for outline errors compared to any required document structure. /// /// public void CheckDocumentStructure(IssueLogger issues) { - SetExpectedDocumentHeaders(); - if (this.AllowedDocumentHeaders.Any()) + var expectedHeaders = this.DocumentPageType switch { - ValidateDocumentHeaders(this.AllowedDocumentHeaders, this.DocumentHeaders, issues); + PageType.ApiPageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.ApiPageType), + PageType.ResourcePageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.ResourcePageType), + PageType.EnumPageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.EnumPageType), + PageType.ConceptualPageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.ConceptualPageType), + _ => [], + }; + if (expectedHeaders.Count != 0) + { + CheckDocumentHeaders(expectedHeaders, this.DocumentHeaders, issues); } ValidateTabStructure(issues); } - private static bool ContainsMatchingDocumentHeader(DocumentHeader expectedHeader, IReadOnlyList collection, + private static bool ContainsMatchingDocumentHeader(DocumentHeader header, IReadOnlyList headerCollection, bool ignoreCase = false, bool checkStringDistance = false) { - return collection.Any(h => h.Matches(expectedHeader, ignoreCase, checkStringDistance)); + return headerCollection.Any(h => h.Matches(header, ignoreCase, checkStringDistance)); } - private void ValidateDocumentHeaders(List expectedHeaders, IReadOnlyList foundHeaders, IssueLogger issues) + /// + /// Match headers found in doc against expected headers for doc type and report discrepancies + /// + /// Allowed headers to match against + /// Headers to evaluate + /// + private void CheckDocumentHeaders(List expectedHeaders, IReadOnlyList foundHeaders, IssueLogger issues) { int expectedIndex = 0; int foundIndex = 0; while (expectedIndex < expectedHeaders.Count && foundIndex < foundHeaders.Count) { - DocumentHeaderValidationResult result = ValidateDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); - var found = foundHeaders[foundIndex]; - var expected = expectedHeaders[expectedIndex] as ExpectedHeader; + var result = ValidateDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); + var expected = expectedHeaders[expectedIndex] as ExpectedDocumentHeader; // at this point, if header was conditional, the condition has been removed switch (result) { case DocumentHeaderValidationResult.Found: - ValidateDocumentHeaders(expected.ChildHeaders, found.ChildHeaders, issues); + CheckDocumentHeaders(expected.ChildHeaders, found.ChildHeaders, issues); foundIndex++; + //if expecting multiple headers of the same pattern, do not increment expected until last header matching pattern is found - if (!expected.Multiple || (expected.Multiple && foundIndex == foundHeaders.Count)) + if (!expected.AllowMultiple || (expected.AllowMultiple && foundIndex == foundHeaders.Count)) expectedIndex++; + break; case DocumentHeaderValidationResult.FoundInWrongCase: @@ -822,7 +784,7 @@ private void ValidateDocumentHeaders(List expectedHeaders, IReadOnlyList } //if expecting multiple headers of the same pattern, increment expected when last header matching pattern is found - if (expected.Multiple && foundIndex == foundHeaders.Count) + if (expected.AllowMultiple && foundIndex == foundHeaders.Count) { expectedIndex++; } @@ -835,14 +797,14 @@ private void ValidateDocumentHeaders(List expectedHeaders, IReadOnlyList for (int i = expectedIndex; i < expectedHeaders.Count; i++) { - ExpectedHeader missingHeader; - if (expectedHeaders[i] is ExpectedHeader expectedMissingHeader) + ExpectedDocumentHeader missingHeader; + if (expectedHeaders[i] is ExpectedDocumentHeader expectedMissingHeader) { missingHeader = expectedMissingHeader; } else { - missingHeader = (expectedHeaders[i] as ConditionalHeader).Arguments.OfType().First(); + missingHeader = (expectedHeaders[i] as ConditionalDocumentHeader).Arguments.OfType().First(); } if (!ContainsMatchingDocumentHeader(missingHeader, foundHeaders, true, true) && missingHeader.Required) @@ -852,15 +814,23 @@ private void ValidateDocumentHeaders(List expectedHeaders, IReadOnlyList } } + /// + /// Validates a document header against the found headers. + /// + /// The list of expected headers, which may include conditional headers. + /// The list of found headers. + /// Index of the expected header being validated. + /// Index of the found header being compared. + /// The validation result. private DocumentHeaderValidationResult ValidateDocumentHeader(List expectedHeaders, IReadOnlyList foundHeaders, int expectedIndex, int foundIndex) { - if (expectedHeaders[expectedIndex] is ConditionalHeader) + if (expectedHeaders[expectedIndex] is ConditionalDocumentHeader) { return ValidateConditionalDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); } var found = foundHeaders[foundIndex]; - var expected = expectedHeaders[expectedIndex] as ExpectedHeader; + var expected = expectedHeaders[expectedIndex] as ExpectedDocumentHeader; if (expected.Matches(found)) { @@ -879,44 +849,69 @@ private DocumentHeaderValidationResult ValidateDocumentHeader(List expec return DocumentHeaderValidationResult.MisspeltDocumentHeader; } - var mergedExpectedHeaders = ExtractAndMergeConditionalHeaderArgumentsInList(expectedHeaders); - // Expected doesn't match found, check if found is in wrong position or is extra header - if (!ContainsMatchingDocumentHeader(found, mergedExpectedHeaders, true)) + // Check if expected header is in the list of found headers + if (!ContainsMatchingDocumentHeader(expected, foundHeaders, ignoreCase: true, checkStringDistance: true)) { - // Check if header has been misspelt and is in wrong position - if (ContainsMatchingDocumentHeader(found, mergedExpectedHeaders, true, true)) + if (expected.Required) { - return DocumentHeaderValidationResult.MisspeltDocumentHeaderInWrongPosition; + return DocumentHeaderValidationResult.RequiredDocumentHeaderMissing; } + else + { + return DocumentHeaderValidationResult.OptionalDocumentHeaderMissing; + } + } - // This is an additional header that isn't in the expected header collection - return DocumentHeaderValidationResult.ExtraDocumentHeaderFound; + // Check if found header is in wrong position or is an extra header + var mergedExpectedHeaders = FlattenDocumentHeaderHierarchy(expectedHeaders); + if (ContainsMatchingDocumentHeader(found, mergedExpectedHeaders, ignoreCase: true)) + { + return DocumentHeaderValidationResult.DocumentHeaderInWrongPosition; + } + else if (ContainsMatchingDocumentHeader(found, mergedExpectedHeaders, ignoreCase: true, checkStringDistance: true)) + { + return DocumentHeaderValidationResult.MisspeltDocumentHeaderInWrongPosition; } else { - bool expectedMatchesInFoundHeaders = ContainsMatchingDocumentHeader(expected, foundHeaders, true, true); - if (expectedMatchesInFoundHeaders) - { - // This header exists, but is in the wrong position - return DocumentHeaderValidationResult.DocumentHeaderInWrongPosition; - } - else if (!expectedMatchesInFoundHeaders && expected.Required) + return DocumentHeaderValidationResult.ExtraDocumentHeaderFound; + } + } + + /// + /// Flattens a hierarchical structure of document headers into a single list. + /// + /// The list of headers, which may contain nested conditional headers. + /// A flat list containing all document headers. + private static List FlattenDocumentHeaderHierarchy(IReadOnlyList headers) + { + var mergedHeaders = new List(); + foreach (var header in headers) + { + if (header is ExpectedDocumentHeader expectedHeader) { - // Missing a required header! - return DocumentHeaderValidationResult.RequiredDocumentHeaderMissing; + mergedHeaders.Add(expectedHeader); } - else + else if (header is ConditionalDocumentHeader conditionalHeader) { - // Expected wasn't found and is optional - return DocumentHeaderValidationResult.OptionalDocumentHeaderMissing; + mergedHeaders.AddRange(FlattenDocumentHeaderHierarchy(conditionalHeader.Arguments)); } } + return mergedHeaders; } + /// + /// Validates a conditional document header against the found headers. + /// + /// The list of expected headers. + /// The list of found headers. + /// Index of the expected header being validated. + /// Index of the found header being compared. + /// The validation result. private DocumentHeaderValidationResult ValidateConditionalDocumentHeader(List expectedHeaders, IReadOnlyList foundHeaders, int expectedIndex, int foundIndex) { var validationResult = DocumentHeaderValidationResult.None; - var expectedConditionalHeader = expectedHeaders[expectedIndex] as ConditionalHeader; + var expectedConditionalHeader = expectedHeaders[expectedIndex] as ConditionalDocumentHeader; if (expectedConditionalHeader.Operator == ConditionalOperator.OR) { foreach (var header in expectedConditionalHeader.Arguments) @@ -926,8 +921,8 @@ private DocumentHeaderValidationResult ValidateConditionalDocumentHeader(List ExtractAndMergeConditionalHeaderArgumentsInList(IReadOnlyList expectedHeaders) - { - List allHeaders = new List(); - foreach (var header in expectedHeaders) - { - if (header is ExpectedHeader) - { - allHeaders.Add((ExpectedHeader)header); - } - else if (header is ConditionalHeader) - { - var arguments = ExtractAndMergeConditionalHeaderArgumentsInList(((ConditionalHeader)header).Arguments); - allHeaders.Concat(arguments); - } - } - return allHeaders; - } - private enum DocumentHeaderValidationResult { None, diff --git a/ApiDoctor.Validation/DocSet.cs b/ApiDoctor.Validation/DocSet.cs index 6bea9b45..4d110acc 100644 --- a/ApiDoctor.Validation/DocSet.cs +++ b/ApiDoctor.Validation/DocSet.cs @@ -273,11 +273,11 @@ public static T[] TryLoadConfigurationFiles(string path) where T : ConfigFile } catch (JsonException ex) { - Logging.LogMessage(new ValidationWarning(ValidationErrorCode.JsonParserException, file.FullName, "JSON parser error: {0}", ex.Message)); + Logging.LogMessage(new ValidationError(ValidationErrorCode.JsonParserException, file.FullName, "JSON parser error: {0}", ex.Message)); } catch (Exception ex) { - Logging.LogMessage(new ValidationWarning(ValidationErrorCode.JsonParserException, file.FullName, "Exception reading file: {0}", ex.Message)); + Logging.LogMessage(new ValidationError(ValidationErrorCode.JsonParserException, file.FullName, "Exception reading file: {0}", ex.Message)); } } diff --git a/ApiDoctor.Validation/DocumentHeader.cs b/ApiDoctor.Validation/DocumentHeader.cs new file mode 100644 index 00000000..f6257c61 --- /dev/null +++ b/ApiDoctor.Validation/DocumentHeader.cs @@ -0,0 +1,91 @@ +using ApiDoctor.Validation.Config; +using Fastenshtein; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace ApiDoctor.Validation +{ + public class DocumentHeader + { + /// + /// Represents the header level using markdown formatting (1=#, 2=##, 3=###, 4=####, 5=#####, 6=######) + /// + [JsonProperty("level")] + public int Level { get; set; } + + /// + /// Indicates that a header at this level is required. + /// + [JsonProperty("required")] + public bool Required { get; set; } + + /// + /// The expected value of a title or empty to indicate any value + /// + [JsonProperty("title")] + public string Title { get; set; } + + /// + /// Specifies the headers that are allowed/found under this header. + /// + [JsonProperty("headers")] + public List ChildHeaders { get; set; } = new List(); + + public DocumentHeader() { } + + public DocumentHeader(DocumentHeader original) + { + Level = original.Level; + Required = original.Required; + Title = original.Title; + + if (original.ChildHeaders != null) + { + ChildHeaders = []; + foreach (var header in original.ChildHeaders) + { + ChildHeaders.Add(new DocumentHeader(header)); + } + } + } + + internal bool Matches(DocumentHeader found, bool ignoreCase = false, bool checkStringDistance = false) + { + if (checkStringDistance) + return IsMisspelt(found); + + return this.Level == found.Level && DoTitlesMatch(this.Title, found.Title, ignoreCase); + } + + private static bool DoTitlesMatch(string expectedTitle, string foundTitle, bool ignoreCase) + { + StringComparison comparisonType = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + if (expectedTitle.Equals(foundTitle, comparisonType)) + return true; + + if (string.IsNullOrEmpty(expectedTitle) || expectedTitle == "*") + return true; + + if (expectedTitle.StartsWith("* ") && foundTitle.EndsWith(expectedTitle[2..], comparisonType)) + return true; + + if (expectedTitle.EndsWith(" *") && foundTitle.StartsWith(expectedTitle[..^2], comparisonType)) + return true; + + return false; + } + + internal bool IsMisspelt(DocumentHeader found) + { + return this.Level == found.Level && Levenshtein.Distance(this.Title, found.Title) < 3; + } + + public override string ToString() + { + return this.Title; + } + } +} diff --git a/ApiDoctor.Validation/TableSpec/tablespecconverter.cs b/ApiDoctor.Validation/TableSpec/tablespecconverter.cs index b6885653..dc03eeed 100644 --- a/ApiDoctor.Validation/TableSpec/tablespecconverter.cs +++ b/ApiDoctor.Validation/TableSpec/tablespecconverter.cs @@ -75,7 +75,7 @@ public static TableSpecConverter FromDefaultConfiguration() /// /// Convert a tablespec block into one of our internal object model representations /// - public TableDefinition ParseTableSpec(Block tableSpecBlock, Stack headerStack, IssueLogger issues) + public TableDefinition ParseTableSpec(Block tableSpecBlock, Stack headerStack, IssueLogger issues) { List discoveredErrors = new List(); List items = new List(); @@ -250,7 +250,7 @@ private static IEnumerable ParseAuthScopeTable(IMarkdownTab return records; } - private TableDecoder FindDecoderFromHeaderText(Stack headerStack) + private TableDecoder FindDecoderFromHeaderText(Stack headerStack) { foreach (var kvp in this.CommonHeaderContentMap) { From 4ff44dee5f975acd3adb867c585db11276b408b9 Mon Sep 17 00:00:00 2001 From: Millicent Achieng Date: Mon, 11 Mar 2024 16:08:58 +0300 Subject: [PATCH 08/12] Update launch.json --- .vscode/launch.json | 33 +++++++++++---------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 5fe48713..79a32d01 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,27 +11,16 @@ "preLaunchTask": "build", "program": "${workspaceFolder}/ApiDoctor.Console/bin/Debug/net8.0/apidoc.dll", "args": [ - // "generate-snippets", - // "generate-permission-files", - // "--bootstrapping-only", - "check-all", - "--ignore-warnings", - "--path", - // "C:/Repos/test-docs/api-reference/alpha", - // "C:/Repos/test-docs/api-reference/delta", - // "C:/Repos/microsoft-graph-docs/api-reference/v1.0", - "C:/Repos/microsoft-graph-docs/api-reference/beta", - "--log", - "C:/Logs/api-doctor/Logs.txt", - // "--permissions-source-file", - // "https://raw.githubusercontent.com/microsoftgraph/microsoft-graph-devx-content/dev/permissions/new/permissions.json" - // "C:/Users/miachien/Downloads/GraphPermissions.json" - // "--snippet-generator-path", - // "C:/Repos/microsoft-graph-devx-api/CodeSnippetsReflection.App/bin/Debug/net7.0/CodeSnippetsReflection.App.exe", - // "--lang", - // "C#,Java,JavaScript", - // "--git-path", - // "/bin/git" + "generate-snippets", + "--ignore-warnings", + "--path", + "/home/codespace/workspace/microsoft-graph-docs", + "--snippet-generator-path", + "/home/codespace/workspace/microsoft-graph-explorer-api/CodeSnippetsReflection.App/bin/Debug/net8.0/CodeSnippetsReflection.App", + "--lang", + "Java", + "--git-path", + "/bin/git" ], "cwd": "${workspaceFolder}/ApiDoctor.Console", "console": "internalConsole", @@ -44,4 +33,4 @@ "processId": "${command:pickProcess}" } ] -} \ No newline at end of file +} From d1001832b7dcd98691aae9c5dc82fb9fda4748d2 Mon Sep 17 00:00:00 2001 From: Millicent Achieng Date: Mon, 11 Mar 2024 16:26:49 +0300 Subject: [PATCH 09/12] Remove unused code --- ApiDoctor.Validation/DocumentHeader.cs | 4 +--- ApiDoctor.Validation/ExtensionMethods.cs | 18 ------------------ 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/ApiDoctor.Validation/DocumentHeader.cs b/ApiDoctor.Validation/DocumentHeader.cs index f6257c61..8c22f91b 100644 --- a/ApiDoctor.Validation/DocumentHeader.cs +++ b/ApiDoctor.Validation/DocumentHeader.cs @@ -1,9 +1,7 @@ -using ApiDoctor.Validation.Config; -using Fastenshtein; +using Fastenshtein; using Newtonsoft.Json; using System; using System.Collections.Generic; -using System.Runtime.Serialization; namespace ApiDoctor.Validation { diff --git a/ApiDoctor.Validation/ExtensionMethods.cs b/ApiDoctor.Validation/ExtensionMethods.cs index b0c2afa5..81127053 100644 --- a/ApiDoctor.Validation/ExtensionMethods.cs +++ b/ApiDoctor.Validation/ExtensionMethods.cs @@ -791,24 +791,6 @@ public static bool IsLikelyBase64Encoded(this string input) return null; } - public static bool TryParseJson(this string obj, out T result) - { - try - { - var settings = new JsonSerializerSettings - { - MissingMemberHandling = MissingMemberHandling.Error - }; - result = JsonConvert.DeserializeObject(obj, settings); - return true; - } - catch (Exception) - { - result = default; - return false; - } - } - /// /// Checks to see if a collection of errors already includes a similar error (matching Code + Message string) /// From 6b65f56ea1b6363eddd7c08dfcbbcbe23dcb7556 Mon Sep 17 00:00:00 2001 From: Millicent Achieng Date: Mon, 11 Mar 2024 16:38:04 +0300 Subject: [PATCH 10/12] Add null check --- ApiDoctor.Validation/DocFile.cs | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/ApiDoctor.Validation/DocFile.cs b/ApiDoctor.Validation/DocFile.cs index 8f833816..b87d35d1 100644 --- a/ApiDoctor.Validation/DocFile.cs +++ b/ApiDoctor.Validation/DocFile.cs @@ -580,7 +580,8 @@ protected bool ParseMarkdownBlocks(IssueLogger issues) else if (previousHeaderBlock.BlockType == BlockType.h1) { methodDescriptionsData.Add(block.Content); - methodDescription = string.Join(" ", methodDescriptionsData.SkipWhile(x => x.StartsWith("Namespace:"))).ToStringClean(); + // make sure we omit the namespace description as well as the national cloud deployments paragraphs + methodDescription = string.Join(" ", methodDescriptionsData.Where(static x => !x.StartsWith("Namespace:", StringComparison.OrdinalIgnoreCase) && !x.Contains("[national cloud deployments](/graph/deployments)", StringComparison.OrdinalIgnoreCase))).ToStringClean(); issues.Message($"Found description: {methodDescription}"); } } @@ -693,17 +694,20 @@ protected bool ParseMarkdownBlocks(IssueLogger issues) /// public void CheckDocumentStructure(IssueLogger issues) { - var expectedHeaders = this.DocumentPageType switch + if (this.Parent.DocumentStructure != null) { - PageType.ApiPageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.ApiPageType), - PageType.ResourcePageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.ResourcePageType), - PageType.EnumPageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.EnumPageType), - PageType.ConceptualPageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.ConceptualPageType), - _ => [], - }; - if (expectedHeaders.Count != 0) - { - CheckDocumentHeaders(expectedHeaders, this.DocumentHeaders, issues); + var expectedHeaders = this.DocumentPageType switch + { + PageType.ApiPageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.ApiPageType), + PageType.ResourcePageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.ResourcePageType), + PageType.EnumPageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.EnumPageType), + PageType.ConceptualPageType => ExpectedDocumentHeader.CopyHeaders(this.Parent.DocumentStructure.ConceptualPageType), + _ => [], + }; + if (expectedHeaders.Count != 0) + { + CheckDocumentHeaders(expectedHeaders, this.DocumentHeaders, issues); + } } ValidateTabStructure(issues); } From 56d3c651a75af3c1c93767ca3368006c416610d5 Mon Sep 17 00:00:00 2001 From: Millicent Achieng Date: Wed, 13 Mar 2024 12:05:47 +0300 Subject: [PATCH 11/12] Add check for collection size --- ApiDoctor.Validation/DocFile.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ApiDoctor.Validation/DocFile.cs b/ApiDoctor.Validation/DocFile.cs index b87d35d1..56e8c912 100644 --- a/ApiDoctor.Validation/DocFile.cs +++ b/ApiDoctor.Validation/DocFile.cs @@ -934,10 +934,17 @@ private DocumentHeaderValidationResult ValidateConditionalDocumentHeader(List 0) + { + expectedHeaders[expectedIndex] = expectedConditionalHeader.Arguments.First(); + expectedHeaders.InsertRange(expectedIndex + 1, expectedConditionalHeader.Arguments.Skip(1)); - validationResult = ValidateDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); + validationResult = ValidateDocumentHeader(expectedHeaders, foundHeaders, expectedIndex, foundIndex); + } + else + { + validationResult = DocumentHeaderValidationResult.ExtraDocumentHeaderFound; + } } return validationResult; } From 265dbbdffb941744ebf8b4da8f37cb240ab9735d Mon Sep 17 00:00:00 2001 From: Millicent Achieng Date: Mon, 25 Mar 2024 16:02:33 +0300 Subject: [PATCH 12/12] Update custom JSON deserializer --- .../Config/DocumentOutlineFile.cs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/ApiDoctor.Validation/Config/DocumentOutlineFile.cs b/ApiDoctor.Validation/Config/DocumentOutlineFile.cs index 9863c05a..c079c338 100644 --- a/ApiDoctor.Validation/Config/DocumentOutlineFile.cs +++ b/ApiDoctor.Validation/Config/DocumentOutlineFile.cs @@ -141,15 +141,22 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist var jArray = JArray.Load(reader); foreach (var item in jArray) { - if (item["condition"] != null) + if (item is JObject jObject) { - var conditionalHeader = item.ToObject(serializer); - allowedHeaders.Add(conditionalHeader); - } - else if (item["title"] != null) - { - var expectedHeader = item.ToObject(serializer); - allowedHeaders.Add(expectedHeader); + object header; + if (jObject.ContainsKey("condition")) + { + header = jObject.ToObject(serializer); + } + else if (jObject.ContainsKey("title")) + { + header = jObject.ToObject(serializer); + } + else + { + throw new JsonReaderException("Invalid document header definition"); + } + allowedHeaders.Add(header); } else {