Transpilers/Rest.psx.ps1

<#
.SYNOPSIS
    Generates PowerShell to talk to a REST api.
.DESCRIPTION
    Generates PowerShell that communicates with a REST api.
.EXAMPLE
    {
        function Get-Sentiment {
            [Rest("http://text-processing.com/api/sentiment/",
                ContentType="application/x-www-form-urlencoded",
                Method = "POST",
                BodyParameter="Text",
                ForeachOutput = {
                    $_ | Select-Object -ExpandProperty Probability -Property Label
                }
            )]
            param()
        }
    } | .>PipeScript | Set-Content .\Get-Sentiment.ps1
.EXAMPLE
    Invoke-PipeScript {
        [Rest("http://text-processing.com/api/sentiment/",
            ContentType="application/x-www-form-urlencoded",
            Method = "POST",
            BodyParameter="Text",
            ForeachOutput = {
                $_ | Select-Object -ExpandProperty Probability -Property Label
            }
        )]
        param()
    } -Parameter @{Text='wow!'}
.EXAMPLE
    {
        [Rest("https://api.github.com/users/{username}/repos",
            QueryParameter={"type", "sort", "direction", "page", "per_page"}
        )]
        param()
    } | .>PipeScript
.EXAMPLE
    Invoke-PipeScript {
        [Rest("https://api.github.com/users/{username}/repos",
            QueryParameter={"type", "sort", "direction", "page", "per_page"}
        )]
        param()
    } -UserName StartAutomating
.EXAMPLE
    {
        [Rest("http://text-processing.com/api/sentiment/",
            ContentType="application/x-www-form-urlencoded",
            Method = "POST",
            BodyParameter={@{
                Text = '
                    [Parameter(Mandatory,ValueFromPipelineByPropertyName)]
                    [string]
                    $Text
                '
            }})]
        param()
    } | .>PipeScript
#>

param(
# The ScriptBlock.
# If not empty, the contents of this ScriptBlock will preceed the REST api call.
[Parameter(ValueFromPipeline)]
[scriptblock]
$ScriptBlock = {},

# One or more REST endpoints. This endpoint will be parsed for REST variables.
[Parameter(Mandatory,Position=0)]
[string[]]
$RESTEndpoint,

# The content type. If provided, this parameter will be passed to the -InvokeCommand.
[string]
$ContentType,

# The method. If provided, this parameter will be passed to the -InvokeCommand.
[string]
$Method,

# The invoke command. This command _must_ have a parameter -URI.
[Alias('Invoker')]
[string]
$InvokeCommand = 'Invoke-RestMethod',

# The name of a variable containing additional invoke parameters.
# By default, this is 'InvokeParams'
[Alias('InvokerParameters','InvokerParameter')]
[string]
$InvokeParameterVariable = 'InvokeParams',

# A dictionary of help for uri parameters.
[Alias('UrlParameterHelp')]
[Collections.IDictionary]
$UriParameterHelp,

# A dictionary of URI parameter types.
[Alias('UrlParameterType')]
[Collections.IDictionary]
$UriParameterType,

# A dictionary or list of parameters for the body.
[PSObject]
$BodyParameter,

# A dictionary or list of query parameters.
[PSObject]
$QueryParameter,

# A script block to be run on each output.
[ScriptBlock]
$ForEachOutput
)

begin {
    # Declare a Regular Expression to match URL variables.
    $RestVariable = [Regex]::new(@'
# Matches URL segments and query strings containing variables.
# Variables can be enclosed in brackets or curly braces, or preceeded by a $ or :
(?> # A variable can be in a URL segment or subdomain
    (?<Start>[/\.]) # Match the <Start>ing slash|dot ...
    (?<IsOptional>\?)? # ... an optional ? (to indicate optional) ...
    (?:
        \{(?<Variable>\w+)\}| # ... A <Variable> name in {} OR
        \[(?<Variable>\w+)\]| # A <Variable> name in [] OR
        \<(?<Variable>\w+)\>| # A <Variable> name in <> OR
        \:(?<Variable>\w+) # A : followed by a <Variable>
    )
|
    (?<IsOptional> # If it's optional it can also be
        [{\[](?<Start>/) # a bracket or brace, followed by a slash
    )
    (?<Variable>\w+)[}\]] # then a <Variable> name followed by } or ]
| # OR it can be in a query parameter:
    (?<Start>[?&]) # Match The <Start>ing ? or & ...
    (?<Query>[\w\-]+) # ... the <Query> parameter name ...
    = # ... an equals ...
    (?<IsOptional>\?)? # ... an optional ? (to indicate optional) ...
    (?:
        \{(?<Variable>\w+)\}| # ... A <Variable> name in {} OR
        \[(?<Variable>\w+)\]| # A <Variable> name in [] OR
        \<(?<Variable>\w+)\>| # A <Variable> name in <> OR
        \:(?<Variable>\w+) # A : followed by a <Variable>
    )
)
'@
, 'IgnoreCase,IgnorePatternWhitespace')

    
    # Next declare a script block that will replace the rest variable.
    $ReplaceRestVariable = {
        param($match)

        if ($uriParameter -and $uriParameter[$match.Groups["Variable"].Value]) {
            return $match.Groups["Start"].Value + $(
                    if ($match.Groups["Query"].Success) { $match.Groups["Query"].Value + '=' }
                ) +
                ([Web.HttpUtility]::UrlEncode(
                    $uriParameter[$match.Groups["Variable"].Value]
                ))
        } else {
            return ''
        }
    }

    $myCmd = $MyInvocation.MyCommand
}

process {
    # First, create a collection of URI parameters.
    $uriParameters = [Ordered]@{}
    # Then, walk over each potential endpoint
    foreach ($endpoint in $RESTEndpoint) {
        # and each match of a $RestVariable
        foreach ($match in $RestVariable.Matches($endpoint)) {
            # The name of the parameter will be in the named capture ${Variable}.
            $parameterName = $match.Groups["Variable"].Value
            # The parameter type will be a string
            $parameterType = if ($UriParameterType.$parameterName) {
                if ($UriParameterType.$parameterName -as [type]) {
                    $UriParameterType.$parameterName
                }
            } else {
                '[string]'
            }
            # and we'll need to put it in the proper parameter set.
            $parameterAttribute = "[Parameter($(
                if (-not $match.Groups["IsOptional"].Value) {'Mandatory'}
            ),ValueFromPipelineByPropertyName,ParameterSetName='$endpoint')]"

            # Combine these three pieces to create the parameter attribute.
            $uriParameters[$parameterName] = @(
                if ($UriParameterHelp -and $UriParameterHelp.$parameterName) {
                    if ($UriParameterHelp.$parameterName -notmatch '^\<{0,1}\#' ) {
                        if ($UriParameterHelp.$parameterName -match '[\r\n]') {
                            "<# " + $UriParameterHelp.$parameterName + "#>" 
                        } else {
                            "# " + $UriParameterHelp.$parameterName
                        }
                    } else {
                        $UriParameterHelp.$parameterName
                    }
                }
                $parameterAttribute
                $parameterType
                '$' + $parameterName
            ) -join [Environment]::Newline
        }        
    }

    # Create a parameter block out of the uri parameters.
    $uriParamBlock  = 
        New-PipeScript -Parameter $uriParameters
    
    # Next, create a parameter block out of any of the body parameters.
    $bodyParamBlock = 
        if ($BodyParameter) {
            New-PipeScript -Parameter $BodyParameter
        } else { {} }
    
    # And one for each of the query parameters.
    $QueryParamblock =
        if ($QueryParameter) {
            New-PipeScript -Parameter $QueryParameter
        } else { {} }
        
    $myBeginBlock = 
        # If we used any URI parameters
        if ($uriParamBlock.Ast.ParamBlock.Parameters) {
            # Carry on the begin block from this command (this is a neat trick)
            [scriptblock]::Create($myCmd.ScriptBlock.Ast.BeginBlock.Extent.ToString())
        } else { { begin { $myCmd = $MyInvocation.MyCommand }} }
        
    # Next, collect the names of bodyParameters, queryParameters, and uriParameters.
    $bodyParameterNames  = 
        foreach ($param in $bodyParamBlock.Ast.ParamBlock.Parameters) { $param.Name -replace '^\$' }
    $queryParameterNames =
        foreach ($param in $QueryParamblock.Ast.ParamBlock.Parameters) { $param.Name -replace '^\$' }
    $uriParameterNames   =
        foreach ($param in $uriParamBlock.Ast.ParamBlock.Parameters)   { $param.Name -replace '^\$' }
    
    
    # Collect all of the parts of the script
    $RestScript = @(
        # Start with the underlying script block
        $ScriptBlock

        # Then include the begin block from this command (or declare myCmd)
        $myBeginBlock

        # Then declare the initial variables.
        [scriptblock]::Create((@"
process {
    `$InvokeCommand = '$InvokeCommand'
    `$invokerCommandinfo =
        `$ExecutionContext.SessionState.InvokeCommand.GetCommand('$InvokeCommand', 'All')
    `$method = '$Method'
    `$contentType = '$contentType'
    `$bodyParameterNames = @('$($bodyParameterNames -join "','")')
    `$queryParameterNames = @('$($queryParameterNames -join "','")')
    `$uriParameterNames = @('$($uriParameterNames -join "','")')
    `$endpoints = @("$($endpoint -join "','")")
    `$ForEachOutput = {
        $(if ($foreachOutput) { $ForEachOutput | .>Pipescript })
    }
    if (`$ForEachOutput -match '^\s{0,}$') {
        `$ForEachOutput = `$null
    }
}
"@
))
    # Next, add some boilerplate code for error handling and setting defaults
{
process {
    if (-not $invokerCommandinfo) {
        Write-Error "Unable to find invoker '$InvokeCommand'"
        return        
    }
    if (-not $psParameterSet) { $psParameterSet = $psCmdlet.ParameterSetName}
    if ($psParameterSet -eq '__AllParameterSets') { $psParameterSet = $endpoints[0]}    
}
}
    # If we had any uri parameters
    if ($uriParameters.Count) {
        # Add the uri parameter block
        $uriParamBlock                
        # Then add a bit to process {} to extract out the URL
{
process {
    $originalUri = "$psParameterSet"
    if (-not $PSBoundParameters.ContainsKey('UriParameter')) {
        $uriParameter = [Ordered]@{}
    }
    foreach ($uriParameterName in $uriParameterNames) {
        if ($psBoundParameters.ContainsKey($uriParameterName)) {
            $uriParameter[$uriParameterName] = $psBoundParameters[$uriParameterName]
        }
    }

    $uri = $RestVariable.Replace($originalUri, $ReplaceRestVariable)
}
}            
    } else {
        # If uri parameters were not supplied, default to the first endpoint.
{
    process {
        $uri = $endpoints[0]
    }
}
    }
    # Now create the invoke splat and populate it.
{
process {
    $invokeSplat = @{}
    $invokeSplat.Uri = $uri
    if ($method) {
        $invokeSplat.Method = $method
    }
    if ($ContentType) {
        $invokeSplat.ContentType = $ContentType
    }
}
}

    # If we have an InvokeParameterVariable
    if ($InvokeParameterVariable) {
        # Create the code that looks for it and joins it with the splat.
        $InvokeParameterVariable = $InvokeParameterVariable -replace '^\$'
[scriptblock]::Create("
process {
    if (`$$InvokeParameterVariable -and `$$InvokeParameterVariable -is [Collections.IDictionary]) {
        `$invokeSplat += `$$InvokeParameterVariable
    }
}
"
)

    }

    # If QueryParameter Names were provided
    if ($queryParameterNames) {
        # Include the query parameter block
        $QueryParamblock
        # And a section of process to handle query parameters.
{
process {
    $QueryParams = [Ordered]@{}
    foreach ($QueryParameterName in $QueryParameterNames) {
        if ($PSBoundParameters.ContainsKey($QueryParameterName)) {
            $QueryParams[$QueryParameterName] = $PSBoundParameters[$QueryParameterName]            
        }
    }
}
}    
{
process {
    foreach ($qp in @($QueryParams.GetEnumerator())) {
        $qpValue = 
            if ($qp.Value -is [DateTime]) {
                $qp.Value.Tostring('o')
            }
            elseif ($qp.Value -is [switch]) {
                $qp.Value -as [bool]
            }
            else {
                $qp.Value
            }

        $queryParams[$qp.Key] = $qpValue
    }

    if ($invokerCommandinfo.Parameters['QueryParameter'] -and 
        $invokerCommandinfo.Parameters['QueryParameter'].ParameterType -eq [Collections.IDictionary]) {
        $invokeSplat.QueryParameter = $QueryParams
    } else {
        $queryParamStr = 
            @(foreach ($qp in $QueryParams.GetEnumerator()) {
                "$($qp.Key)=$([Web.HttpUtility]::UrlEncode($qpValue).Replace('+', '%20'))"
            }) -join '&'
        if ($invokeSplat.Uri.Contains('?')) {
            $invokeSplat.Uri = "$($invokeSplat.Uri)" + '&' + $queryParamStr
        } else {
            $invokeSplat.Uri = "$($invokeSplat.Uri)" + '?' + $queryParamStr
        }
    }
}
}
    }

    # If any body parameters exist
    if ($bodyParameterNames) {
        # Include the body parameter block
        $bodyParamBlock
        # and a process section to handle the body
{
process {
    $completeBody = [Ordered]@{}
    foreach ($bodyParameterName in $bodyParameterNames) {
        if ($bodyParameterName) {
            if ($PSBoundParameters.ContainsKey($bodyParameterName)) {
                $completeBody[$bodyParameterName] = $PSBoundParameters[$bodyParameterName]
            }
        }
    }

    foreach ($bodyPart in @($completeBody.GetEnumerator())) {
        $bodyValue = 
            if ($bodyPart.Value -is [DateTime]) {
                $bodyPart.Value.ToString('o')
            } 
            elseif ($bodyPart.Value -is [switch]) {
                $bodyPart.Value -as [bool]
            }
            else {
                $bodyPart.Value
            }

        $completeBody[$bodyPart.Key] = $bodyValue
    }

    $bodyContent = 
        if ($ContentType -match 'x-www-form-urlencoded') {
            @(foreach ($bodyPart in $completeBody.GetEnumerator()) {
                "$($bodyPart.Key.ToString().ToLower())=$([Web.HttpUtility]::UrlEncode($bodyPart.Value))"
            }) -join '&'
        } elseif ($ContentType -match 'json') {
            ConvertTo-Json $completeBody
        }

    if ($bodyContent -and $method -ne 'get') {
        $invokeSplat.Body = $bodyContent
    }    
}
}
    }
    
    # Last but not least, include the part of process that calls the REST api.
    {
process {
    Write-Verbose "$($invokeSplat.Uri)"
    if ($ForEachOutput) {
        if ($ForEachOutput.Ast.ProcessBlock) {
            & $invokerCommandinfo @invokeSplat | & $ForEachOutput
        } else {
            & $invokerCommandinfo @invokeSplat | ForEach-Object -Process $ForEachOutput
        }        
    } else {
        & $invokerCommandinfo @invokeSplat
    }
}
    }
    )
    
    # Join all of the parts together and you've got yourself a RESTful function.
    $RestScript | 
        Join-PipeScript
}