Trelica.psm1

Add-Type -AssemblyName System.Web

<#
    .SYNOPSIS
    Get or Set Trelica API credentials. Once set, these credentials are persisted to a file for future use.
     
    .DESCRIPTION
    The first time this is run you will be prompted to enter your Trelica API Client ID and Client Secret.
     
    The credentials will be stored in a file for future use. By default they will be added to a folder
    called Trelica in the user's home directory.The StorePath parameter controls the location of this file.
 
    If you are running on Windows then the credentials are encrypted and decryted using the current user's
    logon credentials via the Windows Data Protection API (DPAPI).
     
    .INPUTS
    None. You cannot pipe objects to Initialize-TrelicaCredentials.
     
    .OUTPUTS
    A Trelia.Credentials object
        @{
            PSTypeName = 'Trelica.Credentials'
            [String]$credential
            [String]$baseUrl
        }
 
    Typically this is passed to Get-TrelicaContext in order to authenticate and receive an access token.
     
    .EXAMPLE
    PS> Initialize-TrelicaCredentials -Reset
 
    Credentials path: C:\Users\Me\Trelica
    PowerShell credential request
    Please enter your Trelica API Client ID and Secret when prompted for User and Password respectively
    User: UaQD5J21ARhKjQyFTYokxkieXqM9SM9RaUMHxQBIPjD
    Password for user UaQD5J21ARhKjQyFTYokxkieXqM9SM9RaUMHxQBIPjD: *******************************************
 
    credential baseUrl
    ---------- -------
    System.Management.Automation.PSCredential https://app.trelica.com
 
    .EXAMPLE
    PS> Initialize-TrelicaCredentials | Get-TrelicaContext | .\MyScript.ps1
 
    .LINK
    https://docs.trelica.com/admin/api/creating-an-app
#>

function Initialize-TrelicaCredentials {
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $false, Position = 1, HelpMessage = "The path to the credential store. Defaults to %Home%\Trelica")]
        [string]$StorePath,
        [Parameter(Mandatory = $false, Position = 2, HelpMessage = "Trelica Base Url.")]
        [string]$BaseUrl = "https://app.trelica.com",
        [Parameter(Mandatory=$false,Position=3,HelpMessage="Do not prompt for the credentials if they cannot be read and throw an exception.")]
        [Switch]$DoNotPrompt,
        [Parameter(Mandatory = $false, Position = 4, HelpMessage = "Reset credentials.")]
        [Switch]$Reset,
        [Parameter(Mandatory = $false, Position = 5, HelpMessage = "Delete credentials.")]
        [Switch]$Delete,
        [Parameter(Mandatory = $false, Position = 6, HelpMessage = "Output credentials.")]
        [Switch]$Show,
        [Parameter(Mandatory = $false, Position = 7, HelpMessage = "Specify credentials name.")]
        [string]$SecretName = "TrelicaAPI"
    )
    begin {
        $ErrorActionPreference = "Stop"
    }
    process {
        if ([String]::IsNullOrEmpty($StorePath)) {
            $StorePath = ($Home | Join-Path -ChildPath "Trelica")
            Write-Host "Credentials path: $StorePath"
        } 
        # ensure path exists
        if (-Not (Test-Path -Path "$StorePath" -PathType Container)) {
            New-Item -Path $StorePath -ItemType Directory | Out-Null
        }
        $filePath = [String]::Format("{0}\{1}.json", $StorePath,$SecretName)
        $fileExists = Test-Path -Path $filePath -PathType Leaf

        if ($Delete.IsPresent) {
            if ($fileExists) {
                Remove-Item -Path $filePath -Force
            }
            Write-Host "Credentials deleted"
            return;
        }
        if ($fileExists -and -not $Reset.IsPresent) {
            # we can load $userName = Get-Content -Path $clientIdPath
            $data = Get-Content -Path $filePath | ConvertFrom-Json 
            $baseUrl = $data.baseUrl
            $userName = $data.clientId
            $password = $data.clientSecret | ConvertTo-SecureString
            $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $userName, $password
            if ($Show.IsPresent) {
                Write-Host "Credentials:"
                Write-Host "------------"
                Write-Host "$baseUrl"
                Write-Host "$userName/$($credential.GetNetworkCredential().Password)"
            }    
        }
        else {
            if ($DoNotPrompt.IsPresent) {
                throw "Stored credentials not found"
            }
            if ([String]::IsNullOrEmpty($BaseUrl)) {
                $BaseUrl = "https://app.trelica.com"
                Write-Host "Base URL defaulting to $BaseUrl"
            } 
            $credential = Get-Credential -Message "Please enter your $SecretName Client ID and Secret when prompted for User and Password respectively"
            if ($null -ne $credential) {
                # save
                $data = @{
                    baseUrl      = $BaseUrl
                    clientId     = $credential.UserName
                    clientSecret = $credential.Password | ConvertFrom-SecureString 
                }
                $data | ConvertTo-Json | Out-File $filePath -Force
            }
        }
        return [PSCustomObject]@{
            PSTypeName = 'Trelica.Credentials'
            credential = $credential
            baseUrl    = $baseUrl
        }
    }
}

function Get-TrelicaCredentials {
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, Position = 1, ValueFromPipeline = $true, HelpMessage = "API Credential.")]
        [System.Management.Automation.PSCredential]$Credential,
        [Parameter(Mandatory = $false, Position = 2, HelpMessage = "Trelica Base Url.")]
        [string]$BaseUrl = "https://app.trelica.com"
    )
    begin {
        $ErrorActionPreference = "Stop"
    }
    process {
        return [PSCustomObject]@{
            PSTypeName = 'Trelica.Credentials'
            credential = $Credential
            baseUrl    = $BaseUrl
        }
    }
}


<#
    .SYNOPSIS
    Connects to Trelica and retrieves an OAuth access token.
     
    .DESCRIPTION
    Uses the OAuth2 Client Credentials flow to get an access token from Trelica with (optionally) a restricted set of scopes.
    If no scopes are passed, then the full set of scopes assigned to the Trelica OAuth2 client are used.
     
    .INPUTS
    A Trelia.Credentials object
        @{
            PSTypeName = 'Trelica.Credentials'
            [String]$credential
            [String]$baseUrl
        }
     
    .OUTPUTS
    A Trelia.Context object
        @{
            PSTypeName = 'Trelica.Context'
            [String]$token
            [String]$baseUrl
        }
 
    Typically this is passed to Invoke-TrelicaRequest in order to make Trelica API requests.
     
    .EXAMPLE
    PS> Initialize-TrelicaCredentials | Get-TrelicaContext -Scopes "Workflows.Read","Workflows.Runs.Read" | Format-List
     
    C:\Users\Me\Trelica
    token : eyJhbGciOiJSUzI1NiIsImtpZCI6IjdD...
    baseUrl : https://app.trelica.com
 
    .EXAMPLE
    PS> Initialize-TrelicaCredentials | Get-TrelicaContext | Invoke-TrelicaRequest -Path "/api/workflows/v1" | ConvertTo-Json -Depth 4
 
    .LINK
    https://docs.trelica.com/admin/api/client-credentials-flow
#>

Function Get-TrelicaContext() {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $true, Position = 1, ValueFromPipeline = $true)][PSTypeName('Trelica.Credentials')]$Credentials,
        [Parameter(Mandatory = $false, Position = 2)][String[]]$Scopes
    )
    begin {
        $ErrorActionPreference = "Stop"
    }
    process {
        $requestAccessTokenUri = "$($Credentials.baseUrl)/connect/token"
        $items = @(
            @("grant_type", "client_credentials"),
            @("client_id", $Credentials.credential.UserName),
            @("client_secret", $Credentials.credential.GetNetworkCredential().Password)
        )
        $body = [string]::join("&", ([Array]$items | ForEach-Object { "$($_[0])=$([uri]::EscapeDataString($_[1]))" }))
        if ($null -ne $Scopes) {
            $body += "&scopes=$([uri]::EscapeDataString([string]::join(" ", $Scopes)))"
        }
        $token = Invoke-RestMethod -Method Post -Uri $requestAccessTokenUri -Body $body -ContentType 'application/x-www-form-urlencoded'
        return [PSCustomObject]@{
            PSTypeName = 'Trelica.Context'
            token = $token.access_token
            baseUrl = $Credentials.baseUrl
        } 
    }
}

<#
    .SYNOPSIS
    Invokes a Trelica API end-point.
     
    .DESCRIPTION
    Invokes a Trelica API end-point using the provided Trelica context.
     
    .INPUTS
    A Trelia.Context object
        @{
            PSTypeName = 'Trelica.Context'
            [String]$token
            [String]$baseUrl
        }
     
    .OUTPUTS
    The result of the API call, or $null in the case of an HTTP 404 (Not found).
 
    .EXAMPLE
    PS> Initialize-TrelicaCredentials | Get-TrelicaContext | Invoke-TrelicaRequest -Path "/api/workflows/v1"
     
    .EXAMPLE
    PS> $context = Initialize-TrelicaCredentials | Get-TrelicaContext
    PS> $context | Invoke-TrelicaRequest -Path "/api/workflows/v1" | ConvertTo-Json -Depth 4
#>

Function Invoke-TrelicaRequest() {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $true, Position = 1, ValueFromPipeline = $true)][PSTypeName('Trelica.Context')]$Context,
        [Parameter(Mandatory = $true)][String]$Path,
        [Parameter(Mandatory = $false)][System.Collections.Hashtable]$QueryStringParams,
        [Parameter(Mandatory = $false)][System.Collections.Hashtable]$PostData,
        [Parameter(Mandatory = $false)][String]$Method = 'GET'
    )
    begin {
        $ErrorActionPreference = "Stop"
    }
    process {
        $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
        $headers.Add("Authorization", "Bearer $($Context.token)")
        $headers.Add("Content-Type", "application/json")
        if ($Path.StartsWith("https://")) {
            $uri = $Path
        }
        else {
            $uri = "$($Context.baseUrl.TrimEnd("/"))/$($Path.TrimStart("/"))"
        }
        if ($QueryStringParams) {
            $prepend = "&"
            if (-not ($uri.ToCharArray() -contains '?')) {
                $prepend = "?"
            } 
            foreach ($key in $QueryStringParams.Keys) {
                $value = $QueryStringParams[$key]
                if ("System.Boolean" -eq $value.GetType().FullName) {
                    $value = @("true", "false")[($value)]            
                } 
                $encodedValue = [System.Web.HttpUtility]::UrlEncode($value) 
                $uri = $uri + "$prepend$key=$encodedValue"
                $prepend = "&"
            }
        }
        # we want to return HTTP 404 as a null
        try { 
            $response = (Invoke-WebRequest -Method $Method -Uri $uri -Body $postData -Headers $headers -ErrorAction Stop) | ConvertFrom-Json
        }
        catch { 
            Write-Host "API [$Method] $uri"
            Write-Host "An exception was caught: $($_.Exception.Message)"
            if ($_.Exception.Response.StatusCode.value__ -eq 404) {
                return $null
            }
            else {
                Write-Host "$($_.Exception.Response)"
                throw
            } 
        }
        return $response 
    }
}