WrikeExplorer.psm1
# GENERAL enum WrikeStatus { Scheduled InProgress Completed Cancelled Failed } # BI EXPORT class WrikeDataExportResource { [string]$Name [uri]$Url } class WrikeDataExport { [string]$Id [datetime]$CompletedDate [WrikeStatus]$Status [WrikeDataExportResource[]]$Resources } enum WrikeColumnDataType { Number String Date Boolean } class WrikeDataExportColumn { [string] $Id [string] $Alias [WrikeColumnDataType] $DataType [object] $ForeignKey } class WrikeDataExportSchema { [string] $Id [string] $Alias [WrikeDataExportColumn[]] $Columns } # CONTACTS enum WrikeUserType { Person Group } class WrikeUserProfile { [string] $AccountId [string] $Email [string] $Role [bool] $External [bool] $Admin [bool] $Owner } class WrikeContact { [string]$Id [string] $FirstName [string] $LastName [WrikeUserType] $Type [WrikeUserProfile[]] $Profiles [uri] $AvatarUrl [string] $TimeZone [string] $Locale [bool] $Deleted [bool] $Me [string[]] $MemberIds [object] $Metadata [bool] $MyTeam [string] $Title [string] $CompanyName [string] $Phone [string] $Location [string] $WorkScheduleId [object] $CurrentBillRate [object] $CurrentCostRate } class WrikeErrorDescription { [string] $Error [string] $ErrorDescription } function ConvertFrom-WrikeErrorDescription { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [WrikeErrorDescription] $WrikeErrorDescription ) process { $categoryMap = @{ invalid_request = 'ProtocolError' invalid_parameter = 'InvalidArgument' parameter_required = 'InvalidData' not_authorized = 'SecurityError' access_forbidden = 'PermissionDenied' not_allowed = 'PermissionDenied' resource_not_found = 'ObjectNotFound' method_not_found = 'InvalidOperation' too_many_requests = 'LimitsExceeded' rate_limit_exceeded = 'LimitsExceeded' server_error = 'NotSpecified' } $e = [exception]::new($WrikeErrorDescription.ErrorDescription) $category = [System.Management.Automation.ErrorCategory]::($categoryMap.($WrikeErrorDescription.Error)) $record = [System.Management.Automation.ErrorRecord]::new($e, $WrikeErrorDescription.Error, $category, $null) Write-Output $record } } function ConvertTo-PlainText { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [securestring] $Secret ) process { Write-Output ([pscredential]::new('a', $Secret).GetNetworkCredential().Password) } } function Get-StandardHeaders { [CmdletBinding()] param () process { $headers = @{ Authorization = "Bearer $($script:Config.Wrike.AccessToken | ConvertTo-PlainText)" } Write-Output $headers } } function Import-AccessToken { [CmdletBinding()] param () process { $path = Join-Path $script:Config.AppDataPath 'accesstoken.txt' if (-not (Test-Path -Path $path)) { return } try { $token = Get-Content -Path $path | ConvertTo-SecureString $script:Config.Wrike.AccessToken = $token } catch { Write-Error "Failed to import Wrike API App access token from '$path'. Consider deleting the file and using Set-WrikeAccessToken again." } } } function Remove-AccessToken { [CmdletBinding()] param ( ) process { $script:Config.Wrike.AccessToken = $null $path = Join-Path $script:Config.AppDataPath 'accesstoken.txt' Remove-Item -Path $path -Force } } function Save-AccessToken { [CmdletBinding()] param ( # Specifies the API App access token to use for authentication [Parameter(Mandatory)] [securestring] $AccessToken, # Specifies that the token should not be persisted to disk [Parameter()] [switch] $Ephemeral ) process { $script:Config.Wrike.AccessToken = $AccessToken if (-not $Ephemeral) { $path = Join-Path $script:Config.AppDataPath 'accesstoken.txt' $AccessToken | ConvertFrom-SecureString | Set-Content -Path $path } } } function ValidateAccessToken { [CmdletBinding()] param () process { if ([string]::IsNullOrWhiteSpace($script:Config.Wrike.AccessToken)) { throw "Wrike API App access token has not been set. Please use Set-WrikeAccessToken." } } } function Clear-WrikeAccessToken { [CmdletBinding(SupportsShouldProcess)] param ( ) process { if ($PSCmdlet.ShouldProcess("Wrike API Access Token", "Clear")) { Remove-AccessToken } } } function Get-WrikeContact { [CmdletBinding()] param ( # Specifies that the Wrike Contact record for the current user should be returned [Parameter()] [Alias('CurrentUser')] [switch] $Me, # Specifies that only deleted Wrike Contact records should be returned [Parameter()] [switch] $Deleted, # Specifies a set of optional fields to be included in the Wrike Contact response model. Normally these values are empty. [Parameter()] [ValidateSet('metadata', 'workScheduleId', 'currentBillRate', 'currentCostRate', IgnoreCase = $false)] [string[]] $Include ) process { $query = @{} if ($MyInvocation.BoundParameters.ContainsKey('Me')) { $query.me = $Me.ToString().ToLower() } if ($MyInvocation.BoundParameters.ContainsKey('Deleted')) { $query.deleted = $Deleted.ToString().ToLower() } if ($MyInvocation.BoundParameters.ContainsKey('Include')) { $json = $Include | ConvertTo-Json -Compress if ($Include.Count -lt 2) { $json = "[$json]" } $query.fields = $json } Invoke-WrikeApi -Path contacts -ResponseType contacts -Query $query } } function Get-WrikeDataExport { [CmdletBinding()] [OutputType([WrikeDataExport])] param() process { Invoke-WrikeApi -Path 'data_export' -ResponseType dataExport } } function Get-WrikeDataExportSchema { [CmdletBinding()] param () process { Invoke-WrikeApi -Path 'data_export_schema' -ResponseType dataExportSchema } } function Get-WrikeExplorerConfig { [CmdletBinding()] param ( ) process { Write-Output $script:Config } } function Invoke-WrikeApi { [CmdletBinding()] param ( # Specifies the HTTP method to use for the API call [Parameter()] [ValidateSet('Get', 'Post', 'Delete', 'Put', 'Patch', 'Head', 'Options', 'Merge', 'Trace', 'Default')] [string] $Method = 'Get', # Specifies the full, or base uri to use for the Wrike API call. By default this will be the BaseUri from the module's configuration file. [Parameter()] [Uri] $Uri = [uri]$script:Config.Wrike.BaseUri, # Specifies the relative path of the Wrike API to call [Parameter()] [string] $Path = [string]::Empty, # Specifies the query string keys and values if applicable [Parameter()] [hashtable] $Query = @{}, # Specifies any additional headers needed. The Authorization header will be added for you, and if you supply it manually it will be overwritten. [Parameter()] [hashtable] $Headers = @{}, # Specifies the body for the API call if applicable [Parameter()] [object] $Body, # Specifies the path to save files for Wrike API calls that are intended to download data [Parameter()] [string] $OutFile, # Specifies the expected response type. This determines the class used to parse the results into a strong type. [Parameter()] [string] $ResponseType ) begin { ValidateAccessToken } process { if ($MyInvocation.BoundParameters.ContainsKey('ResponseType') -and -not $script:ResponseType.ContainsKey($ResponseType)) { throw "Unexpected ResponseType passed to Invoke-WrikeApi: $ResponseType" } $Path = $Path.TrimStart('/') $uriBuilder = [uribuilder]([uri]::new($Uri, $Path)) $queryStringCollection = [System.Web.HttpUtility]::ParseQueryString($uriBuilder.Query) $Query.Keys | ForEach-Object { $queryStringCollection.Add($_, $Query.$_) } $uriBuilder.Query = $queryStringCollection.ToString() $standardHeaders = Get-StandardHeaders $standardHeaders.Keys | ForEach-Object { $Headers.$_ = $standardHeaders.$_ } $requestParams = @{ Method = $Method Uri = $uriBuilder.Uri Headers = $Headers } foreach ($p in 'Body', 'OutFile') { if ($MyInvocation.BoundParameters.ContainsKey($p)) { Write-Verbose "Adding parameter '$p' with value '$($MyInvocation.BoundParameters[$p])' to Invoke-RestMethod invocation " $requestParams.$p = $MyInvocation.BoundParameters[$p] } } try { $response = Invoke-RestMethod @requestParams if ($MyInvocation.BoundParameters.ContainsKey('ResponseType')) { if ($response.kind -eq $ResponseType) { $dataType = $script:ResponseType.$ResponseType foreach ($data in $response.data) { $obj = $dataType::new() foreach ($property in $data | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name) { $obj.$property = $data.$property } Write-Output $obj } } else { Write-Error "Unexpected response from Wrike api: $($response | ConvertTo-Json)" } } elseif ($response) { Write-Output $response } } catch [System.Net.Http.HttpRequestException], [System.InvalidOperationException] { $errorRecord = $_ Write-Verbose "Handling $($errorRecord.Exception.GetType()) exception" if ($errorDescription = ($errorRecord.ErrorDetails.Message | ConvertFrom-Json) -as [WrikeErrorDescription]) { Write-Verbose "Successfully parsed Wrike API error response" $errorRecord = $errorDescription | ConvertFrom-WrikeErrorDescription } Write-Error -ErrorRecord $errorRecord } } } function Request-WrikeDataExport { [CmdletBinding()] [OutputType([WrikeDataExport])] param( # Specifies the maximum age of the BI Export data before a refresh request is issued. [Parameter()] [TimeSpan] $MaxAge = ( New-TimeSpan -Hours 12 ), # Specifies that if a new BI Export request is issued, the command should block until the request is completed and the results are returned [Parameter()] [switch] $Wait, # Specifies the maximum time to wait for a result. [Parameter()] [TimeSpan] $Timeout = [TimeSpan]::MaxValue ) process { $minAge = New-TimeSpan -Hours 1 if ($MaxAge -lt $minAge) { Write-Verbose "MaxAge is less than 1 hour. MaxAge will be set to 1 hour." $MaxAge = $minAge } $data = Get-WrikeDataExport $now = if ($data.CompletedDate.Kind -eq [System.DateTimeKind]::Utc) { (Get-Date).ToUniversalTime() } else { Get-Date } if ($null -ne $data -and $now - $data.CompletedDate -le $MaxAge) { # The last report is not older than MaxAge so we just return it Write-Output $data return } # Either there is no BI Export data available or it's older than MaxAge Write-Verbose "Requesting updated BI Export data" $now = Get-Date $expireTime = if ($Timeout -eq [TimeSpan]::MaxValue) { [datetime]::MaxValue } else { $now.Add($Timeout) } $data = Invoke-WrikeApi -Method Post -Path 'data_export' -ResponseType dataExport if (-not $Wait) { # Return early with a Status of Scheduled or InProgress probably Write-Output $data return } Write-Verbose "Waiting until $expireTime" $completedStates = @([WrikeStatus]::Completed, [WrikeStatus]::Cancelled, [WrikeStatus]::Failed) $requestInterval = New-TimeSpan -Seconds $script:Config.Wrike.RequestIntervalSeconds while ($data.Status -notin $completedStates -and (Get-Date) -lt $expireTime) { Write-Verbose "BI Export status is $($data.Status). Checking again in $requestInterval. . ." Start-Sleep -Seconds $requestInterval.TotalSeconds $data = Request-WrikeDataExport -MaxAge $MaxAge } Write-Output $data } } function Save-WrikeDataExport { [CmdletBinding()] param( # Specifies the WrikeDataExport data returned from Get-WrikeDataExport or Request-WrikeDataExport. If ommitted, a call will be made to Get-WrikeDataExport automatically. [Parameter(ValueFromPipeline)] [WrikeDataExport] $WrikeDataExport, # Specifies the directory where all Wrike BI Export resources will be written as CSV files. Path will be created if it doesn't exist, and existing files will be overwritten. [Parameter(Mandatory)] [string] $Destination ) process { $null = New-Item -Path $Destination -ItemType Directory -Force -ErrorAction Stop if ($null -eq $WrikeDataExport) { $WrikeDataExport = Get-WrikeDataExport if ($null -eq $WrikeDataExport) { Write-Error "There is no Wrike BI Export data available. Please use Request-WrikeDataExport to refresh the BI Export data." return } } foreach ($resource in $WrikeDataExport.Resources) { $requestParams = @{ Uri = $resource.Url OutFile = Join-Path $Destination "$($resource.Name).csv" } $ProgressPreference = 'SilentlyContinue' Invoke-WrikeApi @requestParams } } } function Set-WrikeAccessToken { [CmdletBinding(SupportsShouldProcess)] param ( # Specifies the API App access token to use for authentication. It is recommended to provide a securestring, but a string can also be provided. [Parameter(Mandatory)] [object] $AccessToken, # Specifies that the token should not be persisted to disk [Parameter()] [switch] $Ephemeral ) process { $valueType = $AccessToken.GetType().Name if ($valueType -eq 'String') { Write-Warning "The AccessToken value was received as a string. For better security it is recommended to use a securestring instead. Otherwise it's possible your PowerShell command history could be read, and secrets like these could be stolen. Try using 'Read-Host -AsSecureString' to collect tokens, passwords, and secrets in the future." Write-Verbose 'Converting the AccessToken string to a securestring' $AccessToken = $AccessToken | ConvertTo-SecureString -AsPlainText -Force } elseif ($valueType -ne 'SecureString') { Write-Error "AccessToken is expected to be of type SecureString or String. Received a $valueType instead." } if ($PSCmdlet.ShouldProcess("Wrike API Access Token", "Set")) { Write-Verbose "Setting the Wrike API access token. Ephemeral = $Ephemeral" Save-AccessToken -AccessToken $AccessToken -Ephemeral:$Ephemeral } } } $script:Config = Import-PowerShellDataFile $PSScriptRoot\config.psd1 if ($script:Config.AppDataPath -eq 'Default') { $script:Config.AppDataPath = Join-Path -Path ([System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::ApplicationData)) -ChildPath 'WrikeExplorer' } if (-not (Test-Path $script:Config.AppDataPath)) { New-Item -Path $script:Config.AppDataPath -ItemType Directory } $script:ResponseType = @{ contacts = [WrikeContact] dataExport = [WrikeDataExport] dataExportSchema = [WrikeDataExportSchema] } Import-AccessToken |