Skip to content

Argument Transformation Attributes

Introduction

Argument transformation attributes make it possible to offer your users some flexibility in how they supply values for parameters. I've started to use these in the MilestonePSTools module to make it possible to provide a name instead of a strongly typed object like a [RecordingServer] or a [Role], while still making it clear in the Get-Help documentation what the expected object type is, and without polluting functions with object transformation code.

In my last post I introduced argument completers, and I consider these an absolute necessity for PowerShell functions and modules that will be shared and used by many people. Argument transformation attributes on the other hand are an advanced feature that can look intimidating to those early in their PowerShell journey. They are, in my opinion, syntactic sugar and thus purely optional.

Example use case

It has been almost 4 years since the first release of he MilestonePSTools module. In that time, one common question has been about ParameterBindingException errors. For example, if you wanted to get all hardware (cameras) from a recording server, you might try Get-VmsHardware -RecordingServer 'Docker Recorder' which seems perfectly reasonable. But you'd be met with the following error...

Cannot bind parameter 'RecordingServer'. Cannot convert the "Docker Recorder" value of type "System.String" to type "VideoOS.Platform.ConfigurationItems.RecordingServer".

Parameter binding exception error message

The Get-VmsHardware function expects a recording server object instead of a recording server name. The correct usage would then look like...

$recorder = Get-VmsRecordingServer -Name 'Docker Recorder'

# Pipe the recording server in to Get-VmsHardware
$recorder | Get-VmsHardware

# Or provide it as a named parameter
Get-VmsHardware -RecordingServer $recorder

By introducing an argument transformation attribute, we can make the Get-VmsHardware function accept either a recording server object, or a recording server name, without changing the parameter type or any of the code within the begin, process, or end blocks.

Writing an argument transformation attribute

An argument transformation attribute must be written as a class and inherit from the System.Management.Automation.ArgumentTransformationAttribute class. Your class must then override the Transform(EngineIntrinsics, Object) abstract method which is where the code that performs the object transformation will go.

Below you will find my RecorderNameTransformAttribute implementation. I only want it to transform strings into recording server objects. If the value provided by the user is $null or is not a string, then the object will be returned as-is. I could do additional checking during the argument transformation, but PowerShell's own parameter binding and handling of null or invalid types is already so good. Why reinvent the wheel?

class RecorderNameTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {

    ## Override the abstract method "Transform". This is where the user
    ## provided value will be inspected and transformed if possible.
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {

        ## Index recording servers in a hashtable by name so that we can look
        ## them up by name quickly later, without multiple enumerations.
        $recorders = @{}
        Get-VmsRecordingServer | Foreach-Object {
            $recorders[$_.Name] = $_
        }

        # $inputData could be a single object or an array, and each element
        # could be $null, or any other type. The only thing we are interested
        # in are strings. We'll return everything else unaltered and let
        # PowerShell throw an error if necessary.
        return ($inputData | Foreach-Object {
            $obj = $_
            if ($obj -is [string]) {
                if ($recorders.ContainsKey($obj)) {
                    $obj = $recorders[$obj]
                } else {
                    throw [VideoOS.Platform.PathNotFoundMIPException]::new('Recording server "{0}" not found.' -f $_)
                }
            }
            $obj
        })
    }

    [string] ToString() {
        return '[RecorderNameTransformAttribute()]'
    }
}

Using the argument transform in Get-VmsHardware

Once the argument transformation attribute class has been defined, it can be used in any cmdlet or function in your script or module by adding [RecorderNameTransformAttribute()] (or whatever you decide to call your custom attribute) between [Parameter()] and the parameter name. In the definition of Get-VmsHardware below, the highlighted line was the only change required for the function to accept recording servers by name.

function Get-VmsHardware {
    [CmdletBinding(DefaultParameterSetName = 'RecordingServer')]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'RecordingServer')]
        [RecorderNameTransformAttribute()] # (1)!
        [ValidateNotNull()]# (2)!
        [VideoOS.Platform.ConfigurationItems.RecordingServer[]]
        $RecordingServer,

        [Parameter(Position = 0, Mandatory, ParameterSetName = 'Id')]
        [Alias('HardwareId')]
        [guid[]]
        $Id
    )

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'RecordingServer' {
                if (-not $MyInvocation.BoundParameters.ContainsKey('RecordingServer')) {
                    Get-VmsRecordingServer | Get-VmsHardware
                } else {
                    $RecordingServer | Foreach-Object {
                        $_.HardwareFolder.Hardwares
                    }
                }
            }

            'Id' {
                $serverId = (Get-Site).FQID.ServerId
                $Id | ForEach-Object {
                    [VideoOS.Platform.ConfigurationItems.Hardware]::new($serverId, 'Hardware[{0}]' -f $_)
                }
            }

            default {
                throw "ParameterSetName '$_' not implemented."
            }
        }
    }
}
  1. This attribute is the only change required to the Get-VmsHardware function to enable it to accept recording server names in addition to [RecordingServer] objects.
  2. By adding this attribute we can be sure that all elements in $RecordingServer have a value and are not $null inside the process {} block.

Adding an argument completer

While I hold the opinion that argument transformation attributes are nearly always "extra" and not required, now that it's been implemented for Get-VmsHardware we should probably make it easy to take advantage of using an argument completer.

By adding the argument completer below to the class and function definitions below, the user will be able to tab or list-complete values for the -RecordingServer parameter, making it not only possible to provide a name instead of an object, but easy!

Register-ArgumentCompleter -CommandName Get-VmsHardware -ParameterName RecordingServer -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    # Trim single, or double quotes from the start/end of the word to complete.
    if ($wordToComplete -match '^[''"]') {
        $wordToComplete = $wordToComplete.Trim($Matches.Values[0])
    }

    # Get all unique recorder names starting with the characters provided, if any.
    $escapedWordToComplete = [System.Text.RegularExpressions.Regex]::Escape($wordToComplete)
    Get-VmsRecordingServer | Where-Object Name -match "^$escapedWordToComplete" | Select-Object Name -Unique | ForEach-Object {
        # Wrap the completion in single quotes if it contains any whitespace.
        if ($_.Name -match '\s') {
            "'{0}'" -f $_.Name
        } else {
            $_.Name
        }
    }
}

List completion of the RecordingServer parameter

The final result

We can now put the three code blocks above together and use it! It's important to note though that when you're working with PowerShell classes like the RecorderNameTransformAttribute class in this example, we can't reference the class before defining the class.

What I mean by this is that we can't add the [RecorderNameTransformAttribute()] attribute to a function parameter if the class definition appears somewhere after, or below the Get-VmsHardware function definition. So if you copy & paste the script below as-is, it will work just fine. But if you move the class definition down under the function, PowerShell will complain that it doesn't recognize the RecorderNameTransformAttribute class when it attempts to process the [RecorderNameTransformAttribute()] attribute.

When you use classes in a module, it's important to dot source the file(s) where your class(es) are defined before you dot source your functions. And if you have everything in a single .PSM1 file, put your classes at the top of the file so that they are always available when used in your functions.

The argument completer on the other hand can be defined anywhere, any time, because PowerShell doesn't attempt to invoke the argument completer script block until you have typed the associated command and parameter.

class RecorderNameTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {

    ## Override the abstract method "Transform". This is where the user
    ## provided value will be inspected and transformed if possible.
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {

        ## Index recording servers in a hashtable by name so that we can look
        ## them up by name quickly later, without multiple enumerations.
        $recorders = @{}
        Get-VmsRecordingServer | Foreach-Object {
            $recorders[$_.Name] = $_
        }

        # $inputData could be a single object or an array, and each element
        # could be $null, or any other type. The only thing we are interested
        # in are strings. We'll return everything else unaltered and let
        # PowerShell throw an error if necessary.
        return ($inputData | Foreach-Object {
            $obj = $_
            if ($obj -is [string]) {
                if ($recorders.ContainsKey($obj)) {
                    $obj = $recorders[$obj]
                } else {
                    throw [VideoOS.Platform.PathNotFoundMIPException]::new('Recording server "{0}" not found.' -f $_)
                }
            }
            $obj
        })
    }

    [string] ToString() {
        return '[RecorderNameTransformAttribute()]'
    }
}

function Get-VmsHardware {
    [CmdletBinding(DefaultParameterSetName = 'RecordingServer')]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'RecordingServer')]
        [RecorderNameTransformAttribute()] # (1)!
        [ValidateNotNull()]# (2)!
        [VideoOS.Platform.ConfigurationItems.RecordingServer[]]
        $RecordingServer,

        [Parameter(Position = 0, Mandatory, ParameterSetName = 'Id')]
        [Alias('HardwareId')]
        [guid[]]
        $Id
    )

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'RecordingServer' {
                if (-not $MyInvocation.BoundParameters.ContainsKey('RecordingServer')) {
                    Get-VmsRecordingServer | Get-VmsHardware
                } else {
                    $RecordingServer | Foreach-Object {
                        $_.HardwareFolder.Hardwares
                    }
                }
            }

            'Id' {
                $serverId = (Get-Site).FQID.ServerId
                $Id | ForEach-Object {
                    [VideoOS.Platform.ConfigurationItems.Hardware]::new($serverId, 'Hardware[{0}]' -f $_)
                }
            }

            default {
                throw "ParameterSetName '$_' not implemented."
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsHardware -ParameterName RecordingServer -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    # Trim single, or double quotes from the start/end of the word to complete.
    if ($wordToComplete -match '^[''"]') {
        $wordToComplete = $wordToComplete.Trim($Matches.Values[0])
    }

    # Get all unique recorder names starting with the characters provided, if any.
    $escapedWordToComplete = [System.Text.RegularExpressions.Regex]::Escape($wordToComplete)
    Get-VmsRecordingServer | Where-Object Name -match "^$escapedWordToComplete" | Select-Object Name -Unique | ForEach-Object {
        # Wrap the completion in single quotes if it contains any whitespace.
        if ($_.Name -match '\s') {
            "'{0}'" -f $_.Name
        } else {
            $_.Name
        }
    }
}
  1. This attribute is the only change required to the Get-VmsHardware function to enable it to accept recording server names in addition to [RecordingServer] objects.
  2. By adding this attribute we can be sure that all elements in $RecordingServer have a value and are not $null inside the process {} block.

Comments