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
@@ -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."