This script is intended to mimic the functionality of Microsoft's Xml Document Transformation (XDT) technology, but for settings files using JSON.
My organization had a requirement to transform JSON configurations in a manner similar to how XML configuration files are transformed using Microsoft's Xml Document Transformation (XDT) technology. In build and release pipelines running on Azure DevOps Server 2022, we had to be able to specify the values for an environment in unique settings files, include only the unique values for the given environment in that file, indicate how each setting should be changed (modify, add, or remove), and create complete settings files for each environment at build time.
The solution is a single PowerShell script file which uses JSON property names to indicate transformations. Where XDT adds attributes to indicate how to change a setting, my system uses case-sensitive verbs:
XDT
xdt:Transform="SetAttributes(value)" xdt:Locator="Match(key)"/>
Mine
"REPLACE|key": "value"
It's a simple system that can perform most of the operations performed by XDT using a single file.
The PowerShell script is intended for use in an automated build pipeline. It takes as parameters a path to a base file name (-BaseFileName
) and a folder path (-FileSource
), The assumption is that a settings file and its transformation files would share a common base name (e.g., “appsettings”) and be stored in a common location. There is a third parameter for an output directory (-OutputDirectory
).
The most common scenario would include a directory structure that looks like this:
appsettings.json
appsettings.PROD.json
appsettings.QA.json
At build time, after the application is compiled and stored as an artifact, the PowerShell script creates a complete settings file for QA environments using the files appsettings.json
and appsettings.QA.json
, and a second complete settings file for production environments using appsettings.json
and appsettings.PROD.json
.
The use of a -BaseFileName
parameter serves a special situation in our organization. Some of our applications use more than one settings file. These are applications that host multiple unique components. The main application has a settings file with common things shared by all components (like a database connection string), and additional settings files for each unique component that the main application hosts. In this case, application's settings directory has multiple groups of settings file and looks more like this:
appsettings.json
appsettings.PROD.json
appsettings.QA.json
appsettings.component1.json
appsettings.component1.PROD.json
appsettings.component1.QA.json
appsettings.component2.json
appsettings.component2.PROD.json
appsettings.component2.QA.json
The -BaseFileName
parameter (“appsettings “, “appsettings.component1”, “appsettings.component2”) gives the pipeline control over what files are transformed.
The output directory is intended to hold the transformed configuration for each environment. In the above scenario, two folders – QA and PROD – would be created to hold transformed files and would be stored alongside compiled binaries in a deployment artifact.
At deployment, the configurations would be copied to a target environment from the appropriate folder. A possible improvement would be to store the transformed files together, keeping the target environment in the file name even after the completed transformation, then copying the appropriate file to the target location with a new name.
Each base file is altered according to the content of a transform file with a new file being saved in the output directory. The process repeats for as many transform files as are found in the source folder. The base and transform files are loaded as PSCustomObjects
, then the Edit-SettingsObject
function is called with both objects as parameters named -baseObject
and -transformObject
respectively.
In the Edit-SettingsObject
function, each property of the base file is iterated. If the iterated property can be cast as a PSCustomObject (a complex object), and a complex property with a matching name is found in the transform object, Edit-SettingsObject
is called recursively using those two properties.
When the iterated property is not a PSCustomObject
, or a property with a matching name cannot be found, then the -transformObject
property name is examined. The script looks for a pipe character in the property name. This character is used to supply information in the same way the XDT uses attributes like xdt:Transform="SetAttributes(value)"
. The property name is split into an array on the pipe character. The last element of the array is the property name to match to the base object; the previous elements are the transformation commands. As an example, this property in the transform object...
"REPLACE|enabled": true
...would cause the “enabled” property in the base object to be set to “true”. At this point, only one value is expected before the property name, for an array of length 2.
The allowed command values are REPLACE, MERGE, ADD, and REMOVE.
- REPLACE: replace a property completely, including nested properties
- MERGE: for arrays and objects; merges new values into an existing object
- ADD: adds a property not present in the base object
- REMOVE: removes a property from the base object
All values are case-sensitive
Consider the following JSON as a base object:
{
"object_1": {
"enabled": false,
"id": "7f1ae96f-1769-4d58-b636-ca0800c6550b",
"maximum": 1,
"minimum": 0.125
}
}
And the following as the transform object:
{
"object_1": {
"REPLACE|enabled": true,
"id": "fc23819e-3bd1-436a-9458-c0ed46181c45",
"REPLACE|maximum": 1.667,
"REPLACE|minimum": 0.667,
"ADD|allowOffCycle": true
},
"ADD|object_2": {
"enabled": false,
"id": "c18359fb-30d0-4c6d-a73f-69d1ffca73e1",
"maximum": 2,
"minimum": 0.5
}
}
The function Edit-SettingsObject
would iterate the base object and find the complex property called “object_1”, and it would also find a matching property in the transform object. Therefore, the function would call itself recursively with these objects as parameters.
In the recursion, the function would not find any complex objects in the base property, so to would turn to examine the transform object. It would find three properties with the REPLACE
command, and one with the ADD command. The values in the base object would be overwritten by the values in the transform object, and one property would be added. Th “id” property would be ignored, even thought the value is different in both properties.
The recursion would return to the parent, and, not finding any more properties in the base object, the function will iterate the original transform object. The first property in the transform object, “object_1”, would be ignored since it does not contain pipe character. The second object, “object_2”, would be examined because the name does contain the pipe character. Since the command is ADD
, the property and all it’s values would be added to the base object. The resulting output would look like this:
{
"object_1": {
"enabled": true,
"id": "7f1ae96f-1769-4d58-b636-ca0800c6550b",
"maximum": 1.667,
"minimum": 0.667,
"allowOffCycle": true
},
"object_2": {
"enabled": false,
"id": "c18359fb-30d0-4c6d-a73f-69d1ffca73e1",
"maximum": 2,
"minimum": 0.5
}
}