diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e76f7fe..65549ed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -62,13 +62,13 @@ jobs: run: dotnet nuget add source ${{ github.workspace }}/nuget/local --name AggregateConfigBuildTask - name: Restore IntegrationTests with custom AggregateConfigBuildTask package - run: dotnet restore test/IntegrationTests/IntegrationTests.csproj + run: dotnet restore test/IntegrationTests.sln - name: Build IntegrationTests in Release mode - run: dotnet build test/IntegrationTests/IntegrationTests.csproj --configuration Release -warnaserror + run: dotnet build test/IntegrationTests.sln --configuration Release -warnaserror - name: Run IntegrationTests - run: dotnet test test/IntegrationTests/IntegrationTests.csproj --configuration Release -warnaserror + run: dotnet test test/IntegrationTests.sln --configuration Release -warnaserror - name: Upload integration results artifact uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index 901dd3e..08760fa 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ## Features -- Merge multiple YAML configuration files into a single output format (JSON, Azure ARM parameters, or YAML). +- Merge multiple configuration files into a single output format (JSON, Azure ARM parameters, or YAML). - Support for injecting custom metadata (e.g., `ResourceGroup`, `Environment`) into the output. - Optionally include the source file name in each configuration entry. - Embed output files as resources in the assembly for easy inclusion in your project. @@ -25,7 +25,10 @@ dotnet add package AggregateConfigBuildTask Alternatively, add the following line to your `.csproj` file: ```xml - + + all + native;contentFiles;analyzers;runtime + ``` ## Usage @@ -37,13 +40,6 @@ In your `.csproj` file, use the task to aggregate YAML files and output them in ```xml - - - all - native;contentFiles;analyzers;runtime - - - @@ -54,6 +50,7 @@ In your `.csproj` file, use the task to aggregate YAML files and output them in InputDirectory="Configs" OutputFile="$(MSBuildProjectDirectory)\out\output.json" AddSourceProperty="true" + InputType="Yaml" OutputType="Json" AdditionalProperties="@(AdditionalProperty)" /> @@ -74,13 +71,6 @@ You can also generate Azure ARM template parameters. Here's how to modify the co ```xml - - - all - native;contentFiles;analyzers;runtime - - - @@ -90,7 +80,7 @@ You can also generate Azure ARM template parameters. Here's how to modify the co @@ -104,13 +94,6 @@ You can also output the aggregated configuration back into YAML format: ```xml - - - all - native;contentFiles;analyzers;runtime - - - @@ -134,13 +117,6 @@ You can embed the output files (such as the generated JSON) as resources in the ```xml - - - all - native;contentFiles;analyzers;runtime - - - @@ -173,8 +149,11 @@ In this example: - **AddSourceProperty** *(optional, default=false)*: Adds a `source` property to each object in the output, indicating the YAML file it originated from. - **OutputType** *(required)*: Determines the output format. Supported values: - `Json`: Outputs a regular JSON file. - - `ArmParameter`: Outputs an Azure ARM template parameter file. + - `Arm`: Outputs an Azure ARM template parameter file. - `Yaml`: Outputs a YAML file. +- **InputType** *(optional, default=YAML)*: Determines the input format. Supported values: + - `Json`: Inputs are JSON files with a `.json` extension. + - `Yaml`: Inputs are YAML files with a `.yml` or `.yaml` extension. - **AdditionalProperties** *(optional)*: A collection of custom top-level properties to inject into the final output. Use the `ItemGroup` syntax to pass key-value pairs. ## Example YAML Input @@ -256,7 +235,17 @@ resources: ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License. See the [LICENSE](https://github.com/richardsondev/AggregateConfigBuildTask/blob/main/LICENSE) file for details. + +## Third-Party Libraries + +This project leverages the following third-party libraries: + +- **[YamlDotNet](https://github.com/aaubry/YamlDotNet)** + Used for YAML serialization and deserialization. YamlDotNet is distributed under the MIT License. For detailed information, refer to the [YamlDotNet License](https://github.com/aaubry/YamlDotNet/blob/master/LICENSE.txt). + +- **[YamlDotNet.System.Text.Json](https://github.com/IvanJosipovic/YamlDotNet.System.Text.Json)** + Facilitates type handling for YAML serialization and deserialization, enhancing compatibility with System.Text.Json. This library is also distributed under the MIT License. For more details, see the [YamlDotNet.System.Text.Json License](https://github.com/IvanJosipovic/YamlDotNet.System.Text.Json/blob/main/LICENSE). ## Contributing diff --git a/src/Contracts/OutputTypeEnum.cs b/src/Contracts/InputOutputEnums.cs similarity index 51% rename from src/Contracts/OutputTypeEnum.cs rename to src/Contracts/InputOutputEnums.cs index ff57805..b951c56 100644 --- a/src/Contracts/OutputTypeEnum.cs +++ b/src/Contracts/InputOutputEnums.cs @@ -4,6 +4,14 @@ public enum OutputTypeEnum { Json, Arm, + ArmParameter = Arm, + Yml, + Yaml = Yml + } + + public enum InputTypeEnum + { + Json, Yml, Yaml = Yml } diff --git a/src/Task/AggregateConfig.cs b/src/Task/AggregateConfig.cs index 24831bf..de348f7 100644 --- a/src/Task/AggregateConfig.cs +++ b/src/Task/AggregateConfig.cs @@ -1,14 +1,13 @@ -using System; +using AggregateConfig.Contracts; +using AggregateConfig.FileHandlers; +using AggregateConfigBuildTask; +using Microsoft.Build.Framework; +using System; using System.IO; -using System.Collections.Generic; using System.Linq; -using AggregateConfig.Writers; -using AggregateConfig.Contracts; -using Microsoft.Build.Framework; -using Task = Microsoft.Build.Utilities.Task; using System.Runtime.CompilerServices; -using YamlDotNet.Serialization.NamingConventions; -using YamlDotNet.Serialization; +using System.Text.Json; +using Task = Microsoft.Build.Utilities.Task; [assembly: InternalsVisibleTo("AggregateConfig.Tests.UnitTests")] @@ -21,14 +20,16 @@ public class AggregateConfig : Task [Required] public string InputDirectory { get; set; } + public string InputType { get; set; } + [Required] public string OutputFile { get; set; } - public bool AddSourceProperty { get; set; } = false; - [Required] public string OutputType { get; set; } + public bool AddSourceProperty { get; set; } = false; + public string[] AdditionalProperties { get; set; } public AggregateConfig() @@ -46,7 +47,7 @@ public override bool Execute() try { bool hasError = false; - object finalResult = null; + JsonElement? finalResult = null; OutputFile = Path.GetFullPath(OutputFile); @@ -57,38 +58,56 @@ public override bool Execute() return false; } + if (string.IsNullOrEmpty(InputType) || !Enum.TryParse(InputType, out InputTypeEnum inputType)) + { + inputType = InputTypeEnum.Yaml; + } + + if (!Enum.IsDefined(typeof(InputTypeEnum), inputType)) + { + Console.Error.WriteLine("Invalid InputType."); + return false; + } + string directoryPath = Path.GetDirectoryName(OutputFile); if (!fileSystem.DirectoryExists(directoryPath)) { fileSystem.CreateDirectory(directoryPath); } - var yamlFiles = fileSystem.GetFiles(InputDirectory, "*.yml"); - foreach (var yamlFile in yamlFiles) - { - var yamlContent = fileSystem.ReadAllText(yamlFile); - string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(yamlFile); - - // Deserialize the YAML content - var deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .Build(); + var expectedExtensions = FileHandlerFactory.GetExpectedFileExtensions(inputType); + var files = fileSystem.GetFiles(InputDirectory, "*.*") + .Where(file => expectedExtensions.Contains(Path.GetExtension(file).ToLower())) + .ToList(); - object yamlData = null; + foreach (var file in files) + { + IInputReader outputWriter; + try + { + outputWriter = FileHandlerFactory.GetInputReader(fileSystem, inputType); + } + catch (ArgumentException ex) + { + hasError = true; + Console.Error.WriteLine($"No reader found for file {file}: {ex.Message}"); + continue; + } + JsonElement fileData; try { - yamlData = deserializer.Deserialize(yamlContent); + fileData = outputWriter.ReadInput(file); } catch (Exception ex) { hasError = true; - Console.Error.WriteLine($"Could not parse {yamlFile}: {ex.Message}"); + Console.Error.WriteLine($"Could not parse {file}: {ex.Message}"); continue; } - // Merge the deserialized YAML object into the final result - finalResult = MergeYamlObjects(finalResult, yamlData, yamlFile, AddSourceProperty); + // Merge the deserialized object into the final result + finalResult = ObjectManager.MergeObjects(finalResult, fileData, file, AddSourceProperty); } if (hasError) @@ -102,25 +121,14 @@ public override bool Execute() return false; } - var additionalPropertiesDictionary = ParseAdditionalProperties(AdditionalProperties); - - if (additionalPropertiesDictionary?.Count > 0) + var additionalPropertiesDictionary = JsonHelper.ParseAdditionalProperties(AdditionalProperties); + if (!ObjectManager.InjectAdditionalProperties(ref finalResult, additionalPropertiesDictionary)) { - if (finalResult is IDictionary finalDictionary) - { - foreach (var property in additionalPropertiesDictionary) - { - finalDictionary.Add(property.Key, property.Value); - } - } - else - { - Console.Error.WriteLine($"Additional properties could not be injected since the top-level is not a dictionary."); - return false; - } + Console.Error.WriteLine("Additional properties could not be injected since the top-level is not a JSON object."); + return false; } - var writer = OutputWriterFactory.GetOutputWriter(fileSystem, outputType); + var writer = FileHandlerFactory.GetOutputWriter(fileSystem, outputType); writer.WriteOutput(finalResult, OutputFile); return true; @@ -131,139 +139,5 @@ public override bool Execute() return false; } } - - // Recursively merge two YAML objects - private object MergeYamlObjects(object obj1, object obj2, string source2, bool injectSourceProperty) - { - // If injectSourceProperty is true, inject the source property into the second YAML object - if (injectSourceProperty && obj2 is IDictionary obj2Dict) - { - var firstObj2Value = obj2Dict.FirstOrDefault().Value; - if (firstObj2Value is IList obj2NestedList) - { - foreach (var currentObj2Nested in obj2NestedList) - { - if (currentObj2Nested is IDictionary obj2NestedDict) - { - // Inject the "source" property - obj2NestedDict["source"] = Path.GetFileNameWithoutExtension(source2); - } - } - } - } - - if (obj1 == null) return obj2; - if (obj2 == null) return obj1; - - // Handle merging of dictionaries with string keys (the normal case after conversion) - if (obj1 is IDictionary dict1 && obj2 is IDictionary dict2) - { - foreach (var key in dict2.Keys) - { - if (dict1.ContainsKey(key)) - { - dict1[key] = MergeYamlObjects(dict1[key], dict2[key], source2, injectSourceProperty); - } - else - { - dict1[key] = dict2[key]; - } - } - - return dict1; - } - // Handle merging of dictionaries where keys are of type object (e.g., Dictionary) - else if (obj1 is IDictionary objDict1 && obj2 is IDictionary objDict2) - { - var mergedDict = new Dictionary(objDict1); // Start with obj1's dictionary - - foreach (var key in objDict2.Keys) - { - if (mergedDict.ContainsKey(key)) - { - mergedDict[key] = MergeYamlObjects(mergedDict[key], objDict2[key], source2, injectSourceProperty); - } - else - { - mergedDict[key] = objDict2[key]; - } - } - - return mergedDict; - } - // Handle lists by concatenating them - else if (obj1 is IList list1 && obj2 is IList list2) - { - foreach (var item in list2) - { - list1.Add(item); - } - - return list1; - } - // For scalar values, obj2 overwrites obj1 - else - { - return obj2; - } - } - - // Helper method to convert dictionary keys to strings - private object ConvertKeysToString(object data) - { - if (data is IDictionary dict) - { - var convertedDict = new Dictionary(); - - foreach (var key in dict.Keys) - { - var stringKey = key.ToString(); // Ensure the key is a string - convertedDict[stringKey] = ConvertKeysToString(dict[key]); // Recursively convert values - } - - return convertedDict; - } - else if (data is IList list) - { - var convertedList = new List(); - - foreach (var item in list) - { - convertedList.Add(ConvertKeysToString(item)); // Recursively convert list items - } - - return convertedList; - } - - return data; // Return the item as-is if it's not a dictionary or list - } - - /// - /// Parses the additional properties provided as a string array in the format "key=value". - /// Supports escaping of the '=' sign using '\='. - /// - /// An array of key-value pairs in the form "key=value". - /// A dictionary containing the parsed key-value pairs. - private Dictionary ParseAdditionalProperties(string[] properties) - { - var additionalPropertiesDict = new Dictionary(); - const string unicodeEscape = "\u001F"; - - if (properties != null) - { - foreach (var property in properties) - { - var sanitizedProperty = property.Replace(@"\=", unicodeEscape); - - var keyValue = sanitizedProperty.Split(new[] { '=' }, 2); - - if (keyValue.Length == 2) - { - additionalPropertiesDict[keyValue[0].Replace(unicodeEscape, "=")] = keyValue[1].Replace(unicodeEscape, "="); - } - } - } - return additionalPropertiesDict; - } } } diff --git a/src/Task/AggregateConfigBuildTask.csproj b/src/Task/AggregateConfigBuildTask.csproj index e783e72..da5dd78 100644 --- a/src/Task/AggregateConfigBuildTask.csproj +++ b/src/Task/AggregateConfigBuildTask.csproj @@ -29,6 +29,7 @@ + @@ -43,6 +44,7 @@ licenses/YamlDotNet/LICENSE.txt + licenses/LICENSE diff --git a/src/Task/FileHandlers/ArmParametersFileHandler.cs b/src/Task/FileHandlers/ArmParametersFileHandler.cs new file mode 100644 index 0000000..8514482 --- /dev/null +++ b/src/Task/FileHandlers/ArmParametersFileHandler.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace AggregateConfig.FileHandlers +{ + public class ArmParametersFileHandler : IOutputWriter + { + IFileSystem fileSystem; + + internal ArmParametersFileHandler(IFileSystem fileSystem) + { + this.fileSystem = fileSystem; + } + + /// + public void WriteOutput(JsonElement? mergedData, string outputPath) + { + if (mergedData.HasValue && mergedData.Value.ValueKind == JsonValueKind.Object) + { + var parameters = new Dictionary(); + + foreach (var kvp in mergedData.Value.EnumerateObject()) + { + string type = GetParameterType(kvp.Value); + + parameters[kvp.Name] = new Dictionary + { + ["type"] = type, + ["value"] = kvp.Value + }; + } + + // ARM template structure + var armTemplate = new Dictionary + { + ["$schema"] = "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + ["contentVersion"] = "1.0.0.0", + ["parameters"] = parameters + }; + + var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + var jsonContent = JsonSerializer.Serialize(armTemplate, jsonOptions); + fileSystem.WriteAllText(outputPath, jsonContent); + } + else + { + throw new InvalidOperationException("mergedData is either null or not a valid JSON object."); + } + } + + /// + /// Determines the parameter type for a given JsonElement value, based on Azure ARM template supported types. + /// + /// The JsonElement value to evaluate. + /// A string representing the ARM template parameter type. + private string GetParameterType(JsonElement value) + { + switch (value.ValueKind) + { + case JsonValueKind.String: + return "string"; + + case JsonValueKind.Number: + return "int"; + + case JsonValueKind.True: + case JsonValueKind.False: + return "bool"; + + case JsonValueKind.Array: + return "array"; + + case JsonValueKind.Object: + return "object"; + + default: + throw new ArgumentException("Unsupported type for ARM template parameters."); + } + } + } +} diff --git a/src/Task/FileHandlers/FileHandlerFactory.cs b/src/Task/FileHandlers/FileHandlerFactory.cs new file mode 100644 index 0000000..7175446 --- /dev/null +++ b/src/Task/FileHandlers/FileHandlerFactory.cs @@ -0,0 +1,52 @@ +using AggregateConfig.Contracts; +using System; +using System.Collections.Generic; + +namespace AggregateConfig.FileHandlers +{ + public static class FileHandlerFactory + { + internal static IOutputWriter GetOutputWriter(IFileSystem fileSystem, OutputTypeEnum format) + { + switch (format) + { + case OutputTypeEnum.Json: + return new JsonFileHandler(fileSystem); + case OutputTypeEnum.Yaml: + return new YamlFileHandler(fileSystem); + case OutputTypeEnum.Arm: + return new ArmParametersFileHandler(fileSystem); + default: + throw new ArgumentException("Unsupported format"); + } + } + + internal static IInputReader GetInputReader(IFileSystem fileSystem, InputTypeEnum format) + { + switch (format) + { + case InputTypeEnum.Yaml: + return new YamlFileHandler(fileSystem); + case InputTypeEnum.Json: + return new JsonFileHandler(fileSystem); + default: + throw new ArgumentException("Unsupported input format"); + } + } + + internal static List GetExpectedFileExtensions(InputTypeEnum inputType) + { + switch (inputType) + { + case InputTypeEnum.Json: + return new List { ".json" }; + + case InputTypeEnum.Yaml: + return new List { ".yml", ".yaml" }; + + default: + throw new ArgumentException("Unsupported input type"); + } + } + } +} diff --git a/src/Task/FileHandlers/IInputReader.cs b/src/Task/FileHandlers/IInputReader.cs new file mode 100644 index 0000000..e28ca15 --- /dev/null +++ b/src/Task/FileHandlers/IInputReader.cs @@ -0,0 +1,9 @@ +using System.Text.Json; + +namespace AggregateConfig.FileHandlers +{ + public interface IInputReader + { + JsonElement ReadInput(string inputPath); + } +} diff --git a/src/Task/FileHandlers/IOutputWriter.cs b/src/Task/FileHandlers/IOutputWriter.cs new file mode 100644 index 0000000..48d5d52 --- /dev/null +++ b/src/Task/FileHandlers/IOutputWriter.cs @@ -0,0 +1,9 @@ +using System.Text.Json; + +namespace AggregateConfig.FileHandlers +{ + public interface IOutputWriter + { + void WriteOutput(JsonElement? mergedData, string outputPath); + } +} diff --git a/src/Task/FileHandlers/JsonFileHandler.cs b/src/Task/FileHandlers/JsonFileHandler.cs new file mode 100644 index 0000000..2161e90 --- /dev/null +++ b/src/Task/FileHandlers/JsonFileHandler.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace AggregateConfig.FileHandlers +{ + public class JsonFileHandler : IOutputWriter, IInputReader + { + IFileSystem fileSystem; + + internal JsonFileHandler(IFileSystem fileSystem) + { + this.fileSystem = fileSystem; + } + + /// + public JsonElement ReadInput(string inputPath) + { + var json = fileSystem.ReadAllText(inputPath); + return JsonSerializer.Deserialize(json); + } + + /// + public void WriteOutput(JsonElement? mergedData, string outputPath) + { + var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + var jsonContent = JsonSerializer.Serialize(mergedData, jsonOptions); + fileSystem.WriteAllText(outputPath, jsonContent); + } + } +} diff --git a/src/Task/FileHandlers/YamlFileHandler.cs b/src/Task/FileHandlers/YamlFileHandler.cs new file mode 100644 index 0000000..c1109f4 --- /dev/null +++ b/src/Task/FileHandlers/YamlFileHandler.cs @@ -0,0 +1,42 @@ +using System.IO; +using System.Text.Json; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; +using YamlDotNet.System.Text.Json; + +namespace AggregateConfig.FileHandlers +{ + public class YamlFileHandler : IOutputWriter, IInputReader + { + IFileSystem fileSystem; + + internal YamlFileHandler(IFileSystem fileSystem) + { + this.fileSystem = fileSystem; + } + + /// + public JsonElement ReadInput(string inputPath) + { + using (TextReader reader = fileSystem.OpenText(inputPath)) + { + return new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithTypeConverter(new SystemTextJsonYamlTypeConverter()) + .WithTypeInspector(x => new SystemTextJsonTypeInspector(x)) + .Build() + .Deserialize(reader); + } + } + + /// + public void WriteOutput(JsonElement? mergedData, string outputPath) + { + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + var yamlContent = serializer.Serialize(mergedData); + fileSystem.WriteAllText(outputPath, yamlContent); + } + } +} diff --git a/src/Task/FileSystem/FileSystem.cs b/src/Task/FileSystem/FileSystem.cs index ea29e86..bac451d 100644 --- a/src/Task/FileSystem/FileSystem.cs +++ b/src/Task/FileSystem/FileSystem.cs @@ -39,11 +39,17 @@ public bool DirectoryExists(string path) { return Directory.Exists(path); } - + /// public void CreateDirectory(string directoryPath) { Directory.CreateDirectory(directoryPath); } + + /// + public TextReader OpenText(string path) + { + return File.OpenText(path); + } } } diff --git a/src/Task/FileSystem/IFileSystem.cs b/src/Task/FileSystem/IFileSystem.cs index 1fef996..cb63f9e 100644 --- a/src/Task/FileSystem/IFileSystem.cs +++ b/src/Task/FileSystem/IFileSystem.cs @@ -1,4 +1,6 @@ -namespace AggregateConfig +using System.IO; + +namespace AggregateConfig { /// /// Interface for a file system abstraction, allowing various implementations to handle file operations. @@ -66,5 +68,12 @@ internal interface IFileSystem /// all necessary subdirectories are created. Throws an exception if the directory cannot be created. /// void CreateDirectory(string directoryPath); + + /// + /// Opens a text file for reading and returns a TextReader. + /// + /// The file path to open for reading. + /// A TextReader for reading the file content. + TextReader OpenText(string path); } } diff --git a/src/Task/JsonHelper.cs b/src/Task/JsonHelper.cs new file mode 100644 index 0000000..8db4878 --- /dev/null +++ b/src/Task/JsonHelper.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace AggregateConfigBuildTask +{ + internal static class JsonHelper + { + /// + /// Converts a dictionary of string and JsonElement pairs to a JsonElement. + /// + /// A dictionary containing string keys and JsonElement values. + /// A JsonElement representing the dictionary. + public static JsonElement ConvertToJsonElement(Dictionary dictionary) + { + return ConvertObjectToJsonElement(dictionary); + } + + /// + /// Converts a list of JsonElements to a JsonElement. + /// + /// A list containing JsonElement objects. + /// A JsonElement representing the list. + public static JsonElement ConvertToJsonElement(List list) + { + return ConvertObjectToJsonElement(list); + } + + /// + /// Converts dictionary keys to strings recursively for any dictionary or list. + /// + /// The object to process, can be a dictionary or list. + /// An object where dictionary keys are converted to strings. + public static object ConvertKeysToString(object data) + { + if (data is IDictionary dict) + { + var convertedDict = new Dictionary(); + + foreach (var key in dict.Keys) + { + var stringKey = key.ToString(); // Ensure the key is a string + convertedDict[stringKey] = ConvertKeysToString(dict[key]); // Recursively convert values + } + + return convertedDict; + } + else if (data is IList list) + { + var convertedList = new List(); + + foreach (var item in list) + { + convertedList.Add(ConvertKeysToString(item)); // Recursively convert list items + } + + return convertedList; + } + + return data; // Return the item as-is if it's not a dictionary or list + } + + /// + /// Parses an array of key-value pairs provided as strings in the format "key=value". + /// Supports escaping of the '=' sign using '\='. + /// + /// An array of key-value pairs in the form "key=value". + /// A dictionary containing the parsed key-value pairs. + public static Dictionary ParseAdditionalProperties(string[] properties) + { + var additionalPropertiesDict = new Dictionary(); + const string unicodeEscape = "\u001F"; + + if (properties != null) + { + foreach (var property in properties) + { + var sanitizedProperty = property.Replace(@"\=", unicodeEscape); + + var keyValue = sanitizedProperty.Split(new[] { '=' }, 2); + + if (keyValue.Length == 2) + { + additionalPropertiesDict[keyValue[0].Replace(unicodeEscape, "=")] = keyValue[1].Replace(unicodeEscape, "="); + } + } + } + return additionalPropertiesDict; + } + + /// + /// Converts any object to a JsonElement. + /// + /// The object to convert to JsonElement. + /// A JsonElement representing the object. + public static JsonElement ConvertObjectToJsonElement(object value) + { + var json = JsonSerializer.Serialize(value); + return JsonDocument.Parse(json).RootElement; + } + + /// + /// Converts a JsonElement to a dictionary of string and JsonElement pairs. + /// + /// The JsonElement to convert. + /// A dictionary representing the JsonElement. + public static Dictionary JsonElementToDictionary(JsonElement jsonElement) + { + var dictionary = new Dictionary(); + + foreach (var property in jsonElement.EnumerateObject()) + { + dictionary.Add(property.Name, property.Value); + } + + return dictionary; + } + + /// + /// Converts a JsonElement to an appropriate object representation, such as a dictionary, list, or primitive. + /// + /// The JsonElement to convert. + /// An object representing the JsonElement. + public static object ConvertJsonElementToObject(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + return JsonElementToDictionary(element); + case JsonValueKind.Array: + var list = new List(); + foreach (var item in element.EnumerateArray()) + { + list.Add(ConvertJsonElementToObject(item)); + } + return list; + case JsonValueKind.String: + return element.GetString(); + case JsonValueKind.Number: + return element.TryGetInt64(out long l) ? l : element.GetDouble(); + case JsonValueKind.True: + case JsonValueKind.False: + return element.GetBoolean(); + case JsonValueKind.Null: + return null; + default: + throw new InvalidOperationException($"Unsupported JsonValueKind: {element.ValueKind}"); + } + } + } +} diff --git a/src/Task/ObjectManager.cs b/src/Task/ObjectManager.cs new file mode 100644 index 0000000..b777a74 --- /dev/null +++ b/src/Task/ObjectManager.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace AggregateConfigBuildTask +{ + internal static class ObjectManager + { + /// + /// Merges two JsonElements into a single JsonElement. Will merge nested objects and lists together. + /// + public static JsonElement MergeObjects(JsonElement? obj1, JsonElement? obj2, string source2, bool injectSourceProperty) + { + // If injectSourceProperty is true, inject the source property into the second JSON object + if (injectSourceProperty && obj2.HasValue && obj2.Value.ValueKind == JsonValueKind.Object) + { + var obj2Dict = obj2.Value; + var jsonObject = obj2Dict.EnumerateObject().ToDictionary(p => p.Name, p => p.Value); + + if (jsonObject.FirstOrDefault().Value.ValueKind == JsonValueKind.Array) + { + var firstObj2Value = jsonObject.FirstOrDefault().Value; + var obj2NestedList = firstObj2Value.EnumerateArray().ToList(); + + for (int index = 0; index < obj2NestedList.Count; index++) + { + var currentObj2Nested = obj2NestedList[index]; + + if (currentObj2Nested.ValueKind == JsonValueKind.Object) + { + var nestedObj = currentObj2Nested; + var nestedDict = nestedObj.EnumerateObject().ToDictionary(p => p.Name, p => p.Value); + + // Inject the "source" property + nestedDict["source"] = JsonDocument.Parse($"\"{Path.GetFileNameWithoutExtension(source2)}\"").RootElement; + + // Update the list at the correct index + obj2NestedList[index] = JsonHelper.ConvertToJsonElement(nestedDict); + } + } + + jsonObject[jsonObject.FirstOrDefault().Key] = JsonHelper.ConvertToJsonElement(obj2NestedList); + } + obj2 = JsonHelper.ConvertObjectToJsonElement(jsonObject); + } + + if (obj1 == null) return obj2.HasValue ? obj2.Value : default; + if (obj2 == null) return obj1.HasValue ? obj1.Value : default; + + // Handle merging of objects + if (obj1.Value.ValueKind == JsonValueKind.Object && obj2.Value.ValueKind == JsonValueKind.Object) + { + var dict1 = obj1.Value.EnumerateObject().ToDictionary(p => p.Name, p => p.Value); + var dict2 = obj2.Value.EnumerateObject().ToDictionary(p => p.Name, p => p.Value); + + foreach (var key in dict2.Keys) + { + if (dict1.ContainsKey(key)) + { + dict1[key] = MergeObjects(dict1[key], dict2[key], source2, injectSourceProperty); + } + else + { + dict1[key] = dict2[key]; + } + } + + return JsonHelper.ConvertToJsonElement(dict1); + } + // Handle merging of arrays + else if (obj1.Value.ValueKind == JsonValueKind.Array && obj2.Value.ValueKind == JsonValueKind.Array) + { + var list1 = obj1.Value.EnumerateArray().ToList(); + var list2 = obj2.Value.EnumerateArray().ToList(); + + foreach (var item in list2) + { + list1.Add(item); + } + + return JsonHelper.ConvertToJsonElement(list1); + } + // For scalar values, obj2 overwrites obj1 + else + { + return obj2.Value; + } + } + + /// + /// Injects additional properties into a JSON object if possible. The additional properties are provided as a dictionary and are added to the top-level JSON object. + /// + /// The object that is expected to be a JSON object (JsonElement) where additional properties will be injected. + /// A dictionary of additional properties to inject. + /// True if the properties were successfully injected, false otherwise. + public static bool InjectAdditionalProperties(ref JsonElement? finalResult, Dictionary additionalPropertiesDictionary) + { + if (additionalPropertiesDictionary?.Count > 0) + { + if (finalResult is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Object) + { + var jsonDictionary = JsonHelper.JsonElementToDictionary(jsonElement); + + // Add the properties from additionalPropertiesDictionary, converting values to JsonElement + foreach (var property in additionalPropertiesDictionary) + { + jsonDictionary[property.Key] = JsonHelper.ConvertObjectToJsonElement(property.Value); + } + + finalResult = JsonHelper.ConvertToJsonElement(jsonDictionary); + return true; + } + else + { + Console.Error.WriteLine("Additional properties could not be injected since the top-level is not a JSON object."); + return false; + } + } + + return true; + } + } +} diff --git a/src/Task/Writers/ArmParametersOutputWriter.cs b/src/Task/Writers/ArmParametersOutputWriter.cs deleted file mode 100644 index 86fa311..0000000 --- a/src/Task/Writers/ArmParametersOutputWriter.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json; - -namespace AggregateConfig.Writers -{ - public class ArmParametersOutputWriter : IOutputWriter - { - IFileSystem fileSystem; - - internal ArmParametersOutputWriter(IFileSystem fileSystem) - { - this.fileSystem = fileSystem; - } - - public void WriteOutput(object mergedData, string outputPath) - { - var dataDict = mergedData as Dictionary; - - var parameters = new Dictionary(); - foreach (var kvp in dataDict) - { - string type = GetParameterType(kvp.Value); - parameters[kvp.Key] = new Dictionary - { - ["type"] = type, - ["value"] = kvp.Value - }; - } - - // ARM template structure - var armTemplate = new Dictionary - { - ["$schema"] = "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", - ["contentVersion"] = "1.0.0.0", - ["parameters"] = parameters - }; - - var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; - var jsonContent = JsonSerializer.Serialize(armTemplate, jsonOptions); - fileSystem.WriteAllText(outputPath, jsonContent); - } - - private string GetParameterType(object value) - { - if (value is string) - { - return "string"; - } - else if (value is int || value is long || value is double || value is float) - { - return "int"; - } - else if (value is bool) - { - return "bool"; - } - else if (value is IEnumerable) - { - return "array"; - } - else - { - return "object"; - } - } - } -} diff --git a/src/Task/Writers/IOutputWriter.cs b/src/Task/Writers/IOutputWriter.cs deleted file mode 100644 index c9f86bd..0000000 --- a/src/Task/Writers/IOutputWriter.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace AggregateConfig.Writers -{ - public interface IOutputWriter - { - void WriteOutput(object mergedData, string outputPath); - } -} diff --git a/src/Task/Writers/JsonOutputWriter.cs b/src/Task/Writers/JsonOutputWriter.cs deleted file mode 100644 index 0643cb9..0000000 --- a/src/Task/Writers/JsonOutputWriter.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Text.Json; - -namespace AggregateConfig.Writers -{ - public class JsonOutputWriter : IOutputWriter - { - IFileSystem fileSystem; - - internal JsonOutputWriter(IFileSystem fileSystem) - { - this.fileSystem = fileSystem; - } - - public void WriteOutput(object mergedData, string outputPath) - { - var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; - var jsonContent = JsonSerializer.Serialize(mergedData, jsonOptions); - fileSystem.WriteAllText(outputPath, jsonContent); - } - } -} diff --git a/src/Task/Writers/OutputWriterFactory.cs b/src/Task/Writers/OutputWriterFactory.cs deleted file mode 100644 index 14e2b15..0000000 --- a/src/Task/Writers/OutputWriterFactory.cs +++ /dev/null @@ -1,23 +0,0 @@ -using AggregateConfig.Contracts; -using System; - -namespace AggregateConfig.Writers -{ - public static class OutputWriterFactory - { - internal static IOutputWriter GetOutputWriter(IFileSystem fileSystem, OutputTypeEnum format) - { - switch (format) - { - case OutputTypeEnum.Json: - return new JsonOutputWriter(fileSystem); - case OutputTypeEnum.Yaml: - return new YamlOutputWriter(fileSystem); - case OutputTypeEnum.Arm: - return new ArmParametersOutputWriter(fileSystem); - default: - throw new ArgumentException("Unsupported format"); - } - } - } -} diff --git a/src/Task/Writers/YamlOutputWriter.cs b/src/Task/Writers/YamlOutputWriter.cs deleted file mode 100644 index b13416b..0000000 --- a/src/Task/Writers/YamlOutputWriter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -namespace AggregateConfig.Writers -{ - public class YamlOutputWriter : IOutputWriter - { - IFileSystem fileSystem; - - internal YamlOutputWriter(IFileSystem fileSystem) - { - this.fileSystem = fileSystem; - } - - public void WriteOutput(object mergedData, string outputPath) - { - var serializer = new SerializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .Build(); - var yamlContent = serializer.Serialize(mergedData); - fileSystem.WriteAllText(outputPath, yamlContent); - } - } -} diff --git a/src/UnitTests/TaskTestBase.cs b/src/UnitTests/TaskTestBase.cs index 4f186c4..929eae0 100644 --- a/src/UnitTests/TaskTestBase.cs +++ b/src/UnitTests/TaskTestBase.cs @@ -1,8 +1,8 @@ using AggregateConfig.Contracts; using Microsoft.Build.Framework; using Moq; -using Newtonsoft.Json.Linq; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.Linq; @@ -92,6 +92,7 @@ public void ShouldGenerateArmParameterOutput() JObject parameters = (JObject)armTemplate["parameters"]; Assert.IsNotNull(parameters.GetValue("options")); + Assert.AreEqual("array", parameters.GetValue("options")["type"].ToString()); } [TestMethod] @@ -233,6 +234,7 @@ public void ShouldIncludeAdditionalPropertiesInArmParameters() string output = mockFileSystem.ReadAllText($"{testPath}\\output.json"); var armTemplate = JsonConvert.DeserializeObject>(output); JObject parameters = (JObject)armTemplate["parameters"]; + Assert.AreEqual("array", parameters.GetValue("options")["type"].ToString()); Assert.AreEqual("TestRG", parameters.GetValue("Group")["value"].Value()); Assert.AreEqual("Prod", parameters.GetValue("Environment")["value"].Value()); } @@ -283,5 +285,84 @@ public void ShouldHandleInvalidYamlFormat() // Assert: Verify the task fails due to invalid YAML Assert.IsFalse(result); } + + [TestMethod] + [Description("Test that boolean input values are correctly treated as booleans in the output.")] + public void ShouldCorrectlyParseBooleanValues() + { + // Arrange: Prepare sample YAML data. + mockFileSystem.WriteAllText($"{testPath}\\file1.yml", @" + options: + - name: 'Option 1' + description: 'First option' + isEnabled: true"); + + var task = new AggregateConfig(mockFileSystem) + { + InputDirectory = testPath, + OutputFile = testPath + @"\output.json", + OutputType = OutputTypeEnum.Arm.ToString() + }; + task.BuildEngine = Mock.Of(); + + // Act: Execute the task + bool result = task.Execute(); + + // Assert: Verify additional properties are included in ARM output + Assert.IsTrue(result); + string output = mockFileSystem.ReadAllText($"{testPath}\\output.json"); + var armTemplate = JsonConvert.DeserializeObject>(output); + JObject parameters = (JObject)armTemplate["parameters"]; + Assert.AreEqual("array", parameters.GetValue("options")["type"].ToString()); + Assert.AreEqual("Boolean", parameters.GetValue("options")["value"].First()["isEnabled"].Type.ToString()); + Assert.AreEqual(true, parameters.GetValue("options")["value"].First()["isEnabled"].Value()); + } + + [TestMethod] + [Description("Test that additional properties are correctly added to the ARM parameters output from JSON input.")] + public void ShouldIncludeAdditionalPropertiesInJsonInput() + { + // Arrange: Prepare sample JSON data. + mockFileSystem.WriteAllText($"{testPath}\\file1.json", @" + { + ""options"": [ + { + ""name"": ""Option 1"", + ""description"": ""First option"", + ""isEnabled"": true + } + ] + }"); + + var task = new AggregateConfig(mockFileSystem) + { + InputType = InputTypeEnum.Json.ToString(), + InputDirectory = testPath, + OutputFile = testPath + @"\output.json", + OutputType = OutputTypeEnum.Arm.ToString(), + AddSourceProperty = true, + AdditionalProperties = new Dictionary + { + { "Group", "TestRG" }, + { "Environment", "Prod" } + }.Select(q => $"{q.Key}={q.Value}").ToArray() + }; + task.BuildEngine = Mock.Of(); + + // Act: Execute the task + bool result = task.Execute(); + + // Assert: Verify additional properties are included in ARM output + Assert.IsTrue(result); + string output = mockFileSystem.ReadAllText($"{testPath}\\output.json"); + var armTemplate = JsonConvert.DeserializeObject>(output); + JObject parameters = (JObject)armTemplate["parameters"]; + Assert.AreEqual("TestRG", parameters.GetValue("Group")["value"].Value()); + Assert.AreEqual("Prod", parameters.GetValue("Environment")["value"].Value()); + Assert.AreEqual("String", parameters.GetValue("options")["value"].First()["source"].Type.ToString()); + Assert.AreEqual("file1", parameters.GetValue("options")["value"].First()["source"].Value()); + Assert.AreEqual("Boolean", parameters.GetValue("options")["value"].First()["isEnabled"].Type.ToString()); + Assert.AreEqual(true, parameters.GetValue("options")["value"].First()["isEnabled"].Value()); + } } } diff --git a/src/UnitTests/VirtualFileSystem.cs b/src/UnitTests/VirtualFileSystem.cs index 623f843..0006a0d 100644 --- a/src/UnitTests/VirtualFileSystem.cs +++ b/src/UnitTests/VirtualFileSystem.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; -using System; +using System; +using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; @@ -64,6 +64,7 @@ public string ReadAllText(string path) { return content; } + throw new FileNotFoundException($"The file '{path}' was not found in the virtual file system."); } @@ -123,6 +124,12 @@ public void CreateDirectory(string path) fileSystem[path] = string.Empty; } + /// + public TextReader OpenText(string path) + { + return new StringReader(ReadAllText(path)); + } + /// /// Ensures that the provided directory path ends with a directory separator character. /// diff --git a/test/IntegrationTests.sln b/test/IntegrationTests.sln new file mode 100644 index 0000000..03198ec --- /dev/null +++ b/test/IntegrationTests.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34607.119 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTests", "IntegrationTests\IntegrationTests.csproj", "{C3186E8C-A01C-46A3-BB70-0433374822C2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C3186E8C-A01C-46A3-BB70-0433374822C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3186E8C-A01C-46A3-BB70-0433374822C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3186E8C-A01C-46A3-BB70-0433374822C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3186E8C-A01C-46A3-BB70-0433374822C2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F4C35D56-1D95-4EF7-ACF7-4319177A048E} + EndGlobalSection +EndGlobal diff --git a/test/IntegrationTests/.editorconfig b/test/IntegrationTests/.editorconfig new file mode 100644 index 0000000..1f62320 --- /dev/null +++ b/test/IntegrationTests/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# CS1591: Missing XML comment for publicly visible type or member +dotnet_diagnostic.CS1591.severity = suggestion diff --git a/test/IntegrationTests/EmbeddedResourceTests.cs b/test/IntegrationTests/EmbeddedResourceTests.cs index 21b1300..4700e36 100644 --- a/test/IntegrationTests/EmbeddedResourceTests.cs +++ b/test/IntegrationTests/EmbeddedResourceTests.cs @@ -1,5 +1,4 @@ using System.IO; -using System.Linq; using System.Reflection; using System.Text.Json; @@ -9,8 +8,8 @@ namespace AggregateConfig.Tests.Integration public class EmbeddedResourceTests { [TestMethod] - [DataRow("IntegrationTests.out.output.json")] - [DataRow("IntegrationTests.out.output.parameters.json")] + [DataRow("IntegrationTests.out.test.json")] + [DataRow("IntegrationTests.out.test.parameters.json")] public void ReadEmbeddedResource_DeserializesJsonSuccessfully(string resourceName) { // Arrange diff --git a/test/IntegrationTests/IntegrationTests.csproj b/test/IntegrationTests/IntegrationTests.csproj index e2c1f3e..ffb7b65 100644 --- a/test/IntegrationTests/IntegrationTests.csproj +++ b/test/IntegrationTests/IntegrationTests.csproj @@ -7,6 +7,7 @@ disable false true + true @@ -34,14 +35,14 @@ @@ -53,16 +54,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - diff --git a/tools/localreleasevalidation.ps1 b/tools/localreleasevalidation.ps1 new file mode 100644 index 0000000..b2deb61 --- /dev/null +++ b/tools/localreleasevalidation.ps1 @@ -0,0 +1,54 @@ +# Windows E2E local release validation + +# Step 1: Set up paths +$solutionPath = "src\AggregateConfigBuildTask.sln" +$testProjectPath = "test\IntegrationTests.sln" +$nupkgPath = "src\Task\bin\Release\AggregateConfigBuildTask.1.0.1.nupkg" +$localNugetDir = ($env:APPDATA + "\Roaming\NuGet\nuget\local") +$nugetSourceName = "AggregateConfigBuildTask" + +# Step 2: Restore NuGet packages for AggregateConfigBuildTask.sln +Write-Host "Restoring NuGet packages for $solutionPath..." +dotnet restore $solutionPath + +# Step 3: Build the src/AggregateConfigBuildTask.sln project in Release mode +Write-Host "Building $solutionPath in Release mode..." +dotnet clean $solutionPath +dotnet build $solutionPath --configuration Release -warnaserror + +# Step 4: Run tests for AggregateConfigBuildTask.sln +Write-Host "Running tests for $solutionPath..." +dotnet test $solutionPath --configuration Release + +# Step 5: Copy the nupkg to a common location +Write-Host "Copying .nupkg to the local NuGet folder..." +if (-Not (Test-Path $localNugetDir)) { + New-Item -Path $localNugetDir -ItemType Directory +} +Copy-Item $nupkgPath -Destination $localNugetDir -Force + +# Step 6: Remove existing AggregateConfigBuildTask NuGet source if it exists +$existingSource = dotnet nuget list source | Select-String -Pattern $nugetSourceName +if ($existingSource) { + Write-Host "Removing existing '$nugetSourceName' NuGet source..." + dotnet nuget remove source $nugetSourceName +} + +# Step 7: Add the local NuGet source for the integration tests +Write-Host "Adding the local NuGet source with the name '$nugetSourceName'..." +dotnet nuget add source $localNugetDir --name $nugetSourceName + +# Step 8: Restore NuGet packages for the integration tests project +Write-Host "Restoring NuGet packages for $testProjectPath..." +dotnet restore $testProjectPath + +# Step 9: Build the integration tests project in Release mode +Write-Host "Building $testProjectPath in Release mode..." +dotnet clean $solutionPath +dotnet build $testProjectPath --configuration Release -warnaserror + +# Step 10: Run the integration tests +Write-Host "Running the integration tests for $testProjectPath..." +dotnet test $testProjectPath --configuration Release + +Write-Host "All steps completed successfully."