From eecc8db76ffa28f1b2a0d242095b9ea6d12a5169 Mon Sep 17 00:00:00 2001 From: Mikey O'Toole Date: Mon, 1 Jun 2026 11:38:42 +0100 Subject: [PATCH 1/9] Add ExcludeSLA to Get-HaloTicket --- Public/Get/Get-HaloTicket.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Public/Get/Get-HaloTicket.ps1 b/Public/Get/Get-HaloTicket.ps1 index 8b5801b..47635af 100644 --- a/Public/Get/Get-HaloTicket.ps1 +++ b/Public/Get/Get-HaloTicket.ps1 @@ -76,6 +76,9 @@ function Get-HaloTicket { # Include next activity date in the results. [Parameter( ParameterSetName = 'Multi' )] [switch]$IncludeNextActivityDate, + # Exclude SLA calculations in the results. + [Parameter( ParameterSetName = 'Multi' )] + [switch]$ExcludeSLA, # Filter by the specified ticket area. [Parameter( ParameterSetName = 'Multi' )] [int32]$TicketAreaID, From 08a7422324bb12f8617fc9bdba38890541f2f352 Mon Sep 17 00:00:00 2001 From: Mikey O'Toole Date: Mon, 1 Jun 2026 11:38:49 +0100 Subject: [PATCH 2/9] Fix Invoke-HaloRequest Fragment handling (#84) --- Public/Invoke/Invoke-HaloRequest.ps1 | 11 +++++++++-- Tests/HaloAPI.Unit.Tests.ps1 | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Public/Invoke/Invoke-HaloRequest.ps1 b/Public/Invoke/Invoke-HaloRequest.ps1 index d2a8f4f..f628bcc 100644 --- a/Public/Invoke/Invoke-HaloRequest.ps1 +++ b/Public/Invoke/Invoke-HaloRequest.ps1 @@ -46,9 +46,16 @@ function Invoke-HaloRequest { $Retries = 0 $BaseDelay = 5 # Base delay of 5 seconds $MaxDelay = 60 # Maximum delay of 60 seconds - # Check if $WebRequestParams contains a full URI, if not, append the base URL + $BaseUri = [System.Uri]$Script:HAPIConnectionInformation.URL + # Check for a fragment first so callers can supply just the path portion. + if ($WebRequestParams.Fragment) { + $WebRequestParams.Uri = [System.Uri]::new($BaseUri, $WebRequestParams.Fragment).AbsoluteUri + $WebRequestParams.Remove('Fragment') | Out-Null + } + + # Check if $WebRequestParams contains a full URI, if not, append the base URL. if (-not ([System.Uri]$WebRequestParams.Uri).IsAbsoluteUri) { - $WebRequestParams.Uri = $Script:HAPIConnectionInformation.URL + $WebRequestParams.Uri + $WebRequestParams.Uri = [System.Uri]::new($BaseUri, $WebRequestParams.Uri).AbsoluteUri } do { $Retries++ diff --git a/Tests/HaloAPI.Unit.Tests.ps1 b/Tests/HaloAPI.Unit.Tests.ps1 index 63d0832..b042970 100644 --- a/Tests/HaloAPI.Unit.Tests.ps1 +++ b/Tests/HaloAPI.Unit.Tests.ps1 @@ -267,6 +267,26 @@ Describe 'Invoke-HaloRequest' { } } + It 'resolves Fragment values against the base URL' { + Mock -CommandName 'Invoke-WebRequest' -ModuleName 'HaloAPI' -MockWith { + [pscustomobject]@{ Content = '{"ok":true}' } + } + + InModuleScope 'HaloAPI' { + $Result = Invoke-HaloRequest -WebRequestParams @{ + Method = 'POST' + Fragment = '/api/customtable' + } + + $Result.ok | Should -BeTrue + } + + Should -Invoke -CommandName 'Invoke-WebRequest' -ModuleName 'HaloAPI' -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://example.halo/api/customtable' -and + -not $PSBoundParameters.ContainsKey('Fragment') + } + } + It 'returns the raw web response when RawResult is specified' { Mock -CommandName 'Invoke-WebRequest' -ModuleName 'HaloAPI' -MockWith { [pscustomobject]@{ Content = '{"ok":true}'; StatusCode = 200 } From bec13864d834a76654dfed9629e6254feb5a9d51 Mon Sep 17 00:00:00 2001 From: Mikey O'Toole Date: Mon, 1 Jun 2026 11:53:55 +0100 Subject: [PATCH 3/9] Enhance Invoke-HaloRequest to support individual parameters and improve fragment handling --- Public/Invoke/Invoke-HaloRequest.ps1 | 111 +++++++++++++++++++++++++-- Tests/HaloAPI.Unit.Tests.ps1 | 40 +++++++++- 2 files changed, 141 insertions(+), 10 deletions(-) diff --git a/Public/Invoke/Invoke-HaloRequest.ps1 b/Public/Invoke/Invoke-HaloRequest.ps1 index f628bcc..2aa58ae 100644 --- a/Public/Invoke/Invoke-HaloRequest.ps1 +++ b/Public/Invoke/Invoke-HaloRequest.ps1 @@ -6,6 +6,41 @@ function Invoke-HaloRequest { Sends a request to the Halo API. .DESCRIPTION Wrapper function to send web requests to the Halo API. + + Supports the legacy hashtable request format used internally by the module, + as well as a direct parameter set for clearer public use. + + When using -Fragment, slashless values are treated as Halo API fragments and + are automatically prefixed with /api/. + .PARAMETER WebRequestParams + Hashtable containing the web request parameters. + .PARAMETER Method + HTTP method for the request. + .PARAMETER Uri + Absolute URI for the request. + .PARAMETER Fragment + Fragment path to resolve against the Halo base URL. Slashless fragments are + automatically prefixed with /api/. + .PARAMETER Headers + Request headers to merge with the Halo auth headers. + .PARAMETER Body + Request body. + .PARAMETER ContentType + Content type for the request. + .PARAMETER RawResult + Returns the raw web response. Useful for file downloads. + .EXAMPLE + Invoke-HaloRequest -WebRequestParams @{ Method = 'GET'; Uri = 'https://example.halo/api/customtable' } + + Uses the legacy request hashtable. + .EXAMPLE + Invoke-HaloRequest -Method 'POST' -Uri 'https://example.halo/api/customtable' -Body $Payload + + Uses the direct parameter set for a POST request with an explicit absolute URI. + .EXAMPLE + Invoke-HaloRequest -Method 'GET' -Fragment 'tickets' + + Resolves a slashless fragment to /api/tickets before sending the request. .OUTPUTS Outputs an object containing the response from the web request. #> @@ -13,7 +48,26 @@ function Invoke-HaloRequest { [OutputType([Object])] param ( # Hashtable containing the web request parameters. + [Parameter( ParameterSetName = 'WebRequestParams', Mandatory = $True )] [Hashtable]$WebRequestParams, + # HTTP method for the request. + [Parameter( ParameterSetName = 'RequestParameters', Mandatory = $True )] + [string]$Method, + # URI or path for the request. + [Parameter( ParameterSetName = 'RequestParameters' )] + [string]$Uri, + # Fragment path to resolve against the Halo base URL. + [Parameter( ParameterSetName = 'RequestParameters' )] + [string]$Fragment, + # Request headers to merge with the Halo auth headers. + [Parameter( ParameterSetName = 'RequestParameters' )] + [Hashtable]$Headers, + # Request body. + [Parameter( ParameterSetName = 'RequestParameters' )] + [Object]$Body, + # Content type for the request. + [Parameter( ParameterSetName = 'RequestParameters' )] + [string]$ContentType, # Returns the Raw result. Useful for file downloads. [Switch]$RawResult ) @@ -23,11 +77,11 @@ function Invoke-HaloRequest { if ($Script:HAPIAuthToken.Expires -le $Now) { Write-Verbose 'The auth token has expired, renewing.' $ReconnectParameters = @{ - URL = $Script:HAPIConnectionInformation.URL - ClientId = $Script:HAPIConnectionInformation.ClientID + URL = $Script:HAPIConnectionInformation.URL + ClientId = $Script:HAPIConnectionInformation.ClientID ClientSecret = $Script:HAPIConnectionInformation.ClientSecret - Scopes = $Script:HAPIConnectionInformation.AuthScopes - Tenant = $Script:HAPIConnectionInformation.Tenant + Scopes = $Script:HAPIConnectionInformation.AuthScopes + Tenant = $Script:HAPIConnectionInformation.Tenant } Connect-HaloAPI @ReconnectParameters } @@ -43,13 +97,43 @@ function Invoke-HaloRequest { } else { $RequestHeaders = $null } + if ($PSCmdlet.ParameterSetName -eq 'RequestParameters') { + $WebRequestParams = @{ + Method = $Method + } + + if (-not [string]::IsNullOrWhiteSpace($Uri)) { + $WebRequestParams.Uri = $Uri + } + + if (-not [string]::IsNullOrWhiteSpace($Fragment)) { + $WebRequestParams.Fragment = $Fragment + } + + if ($null -ne $Headers) { + $WebRequestParams.Headers = $Headers + } + + if ($PSBoundParameters.ContainsKey('Body')) { + $WebRequestParams.Body = $Body + } + + if ($PSBoundParameters.ContainsKey('ContentType')) { + $WebRequestParams.ContentType = $ContentType + } + } $Retries = 0 $BaseDelay = 5 # Base delay of 5 seconds $MaxDelay = 60 # Maximum delay of 60 seconds $BaseUri = [System.Uri]$Script:HAPIConnectionInformation.URL # Check for a fragment first so callers can supply just the path portion. if ($WebRequestParams.Fragment) { - $WebRequestParams.Uri = [System.Uri]::new($BaseUri, $WebRequestParams.Fragment).AbsoluteUri + $FragmentPath = $WebRequestParams.Fragment + if ($FragmentPath -notmatch '/') { + $FragmentPath = '/api/{0}' -f $FragmentPath + } + + $WebRequestParams.Uri = [System.Uri]::new($BaseUri, $FragmentPath).AbsoluteUri $WebRequestParams.Remove('Fragment') | Out-Null } @@ -57,12 +141,27 @@ function Invoke-HaloRequest { if (-not ([System.Uri]$WebRequestParams.Uri).IsAbsoluteUri) { $WebRequestParams.Uri = [System.Uri]::new($BaseUri, $WebRequestParams.Uri).AbsoluteUri } + if ($null -ne $WebRequestParams.Headers) { + if ($null -ne $RequestHeaders) { + $RequestHeaders = $RequestHeaders + $WebRequestParams.Headers + } else { + $RequestHeaders = $WebRequestParams.Headers + } + + $WebRequestParams.Remove('Headers') | Out-Null + } + + $RequestContentType = 'application/json; charset=utf-8' + if ($WebRequestParams.ContainsKey('ContentType')) { + $RequestContentType = $WebRequestParams.ContentType + $WebRequestParams.Remove('ContentType') | Out-Null + } do { $Retries++ Write-Verbose ('Attempt {0} of 10' -f $Retries) try { Write-Verbose ('Making a {0} request to {1}' -f $WebRequestParams.Method, $WebRequestParams.Uri) - $Response = Invoke-WebRequest @WebRequestParams -Headers $RequestHeaders -ContentType 'application/json; charset=utf-8' + $Response = Invoke-WebRequest @WebRequestParams -Headers $RequestHeaders -ContentType $RequestContentType if ($Response) { Write-Debug ('Response headers: {0}' -f ($Response.Headers | Out-String)) Write-Debug ('Raw Response: {0}' -f ($Response | Out-String)) diff --git a/Tests/HaloAPI.Unit.Tests.ps1 b/Tests/HaloAPI.Unit.Tests.ps1 index b042970..a8a3d68 100644 --- a/Tests/HaloAPI.Unit.Tests.ps1 +++ b/Tests/HaloAPI.Unit.Tests.ps1 @@ -267,16 +267,32 @@ Describe 'Invoke-HaloRequest' { } } + It 'accepts individual request parameters' { + Mock -CommandName 'Invoke-WebRequest' -ModuleName 'HaloAPI' -MockWith { + [pscustomobject]@{ Content = '{"ok":true}' } + } + + InModuleScope 'HaloAPI' { + $Result = Invoke-HaloRequest -Method 'GET' -Uri 'api/test' + + $Result.ok | Should -BeTrue + } + + Should -Invoke -CommandName 'Invoke-WebRequest' -ModuleName 'HaloAPI' -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://example.halo/api/test' -and + $Headers.Authorization -eq 'Bearer abc123' -and + $Headers['X-Test'] -eq 'HeaderValue' -and + $ContentType -eq 'application/json; charset=utf-8' + } + } + It 'resolves Fragment values against the base URL' { Mock -CommandName 'Invoke-WebRequest' -ModuleName 'HaloAPI' -MockWith { [pscustomobject]@{ Content = '{"ok":true}' } } InModuleScope 'HaloAPI' { - $Result = Invoke-HaloRequest -WebRequestParams @{ - Method = 'POST' - Fragment = '/api/customtable' - } + $Result = Invoke-HaloRequest -Method 'POST' -Fragment '/api/customtable' $Result.ok | Should -BeTrue } @@ -287,6 +303,22 @@ Describe 'Invoke-HaloRequest' { } } + It 'prefixes slashless fragments with api' { + Mock -CommandName 'Invoke-WebRequest' -ModuleName 'HaloAPI' -MockWith { + [pscustomobject]@{ Content = '{"ok":true}' } + } + + InModuleScope 'HaloAPI' { + $Result = Invoke-HaloRequest -Method 'GET' -Fragment 'tickets' + + $Result.ok | Should -BeTrue + } + + Should -Invoke -CommandName 'Invoke-WebRequest' -ModuleName 'HaloAPI' -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://example.halo/api/tickets' + } + } + It 'returns the raw web response when RawResult is specified' { Mock -CommandName 'Invoke-WebRequest' -ModuleName 'HaloAPI' -MockWith { [pscustomobject]@{ Content = '{"ok":true}'; StatusCode = 200 } From 3bf8671d7edf2ce153a525ed4e7d1b56e00a87be Mon Sep 17 00:00:00 2001 From: Mikey O'Toole Date: Mon, 1 Jun 2026 11:57:01 +0100 Subject: [PATCH 4/9] Add validation for HTTP method parameter in Invoke-HaloRequest --- Public/Invoke/Invoke-HaloRequest.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/Public/Invoke/Invoke-HaloRequest.ps1 b/Public/Invoke/Invoke-HaloRequest.ps1 index 2aa58ae..718f1ce 100644 --- a/Public/Invoke/Invoke-HaloRequest.ps1 +++ b/Public/Invoke/Invoke-HaloRequest.ps1 @@ -52,6 +52,7 @@ function Invoke-HaloRequest { [Hashtable]$WebRequestParams, # HTTP method for the request. [Parameter( ParameterSetName = 'RequestParameters', Mandatory = $True )] + [ValidateSet('GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'HEAD', 'OPTIONS')] [string]$Method, # URI or path for the request. [Parameter( ParameterSetName = 'RequestParameters' )] From 25cca40ef3e5035e99926d0eec2c266a3b12bb75 Mon Sep 17 00:00:00 2001 From: Mikey O'Toole Date: Mon, 1 Jun 2026 12:00:27 +0100 Subject: [PATCH 5/9] Add ExpandProperty parameter to Invoke-HaloRequest for JSON response handling --- Public/Invoke/Invoke-HaloRequest.ps1 | 22 ++++++++++++++++++++++ Tests/HaloAPI.Unit.Tests.ps1 | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Public/Invoke/Invoke-HaloRequest.ps1 b/Public/Invoke/Invoke-HaloRequest.ps1 index 718f1ce..48b9042 100644 --- a/Public/Invoke/Invoke-HaloRequest.ps1 +++ b/Public/Invoke/Invoke-HaloRequest.ps1 @@ -27,6 +27,8 @@ function Invoke-HaloRequest { Request body. .PARAMETER ContentType Content type for the request. + .PARAMETER ExpandProperty + Returns the value of a property from the JSON response, such as tickets. .PARAMETER RawResult Returns the raw web response. Useful for file downloads. .EXAMPLE @@ -41,6 +43,10 @@ function Invoke-HaloRequest { Invoke-HaloRequest -Method 'GET' -Fragment 'tickets' Resolves a slashless fragment to /api/tickets before sending the request. + .EXAMPLE + Invoke-HaloRequest -Method 'GET' -Fragment 'tickets' -ExpandProperty 'tickets' + + Returns only the tickets property from the JSON response. .OUTPUTS Outputs an object containing the response from the web request. #> @@ -69,6 +75,10 @@ function Invoke-HaloRequest { # Content type for the request. [Parameter( ParameterSetName = 'RequestParameters' )] [string]$ContentType, + # Property to expand from the JSON response. + [Parameter( ParameterSetName = 'RequestParameters' )] + [Parameter( ParameterSetName = 'WebRequestParams' )] + [string]$ExpandProperty, # Returns the Raw result. Useful for file downloads. [Switch]$RawResult ) @@ -122,6 +132,10 @@ function Invoke-HaloRequest { if ($PSBoundParameters.ContainsKey('ContentType')) { $WebRequestParams.ContentType = $ContentType } + + if (-not [string]::IsNullOrWhiteSpace($ExpandProperty)) { + $WebRequestParams.ExpandProperty = $ExpandProperty + } } $Retries = 0 $BaseDelay = 5 # Base delay of 5 seconds @@ -157,6 +171,11 @@ function Invoke-HaloRequest { $RequestContentType = $WebRequestParams.ContentType $WebRequestParams.Remove('ContentType') | Out-Null } + $ResponseExpandProperty = $null + if ($WebRequestParams.ContainsKey('ExpandProperty')) { + $ResponseExpandProperty = $WebRequestParams.ExpandProperty + $WebRequestParams.Remove('ExpandProperty') | Out-Null + } do { $Retries++ Write-Verbose ('Attempt {0} of 10' -f $Retries) @@ -172,6 +191,9 @@ function Invoke-HaloRequest { $Results = $Response } else { $Results = ($Response.Content | ConvertFrom-Json -Depth 100) + if ($ResponseExpandProperty) { + $Results = $Results | Select-Object -ExpandProperty $ResponseExpandProperty + } } } else { Write-Debug 'Response was null.' diff --git a/Tests/HaloAPI.Unit.Tests.ps1 b/Tests/HaloAPI.Unit.Tests.ps1 index a8a3d68..adede88 100644 --- a/Tests/HaloAPI.Unit.Tests.ps1 +++ b/Tests/HaloAPI.Unit.Tests.ps1 @@ -319,6 +319,24 @@ Describe 'Invoke-HaloRequest' { } } + It 'expands a named response property' { + Mock -CommandName 'Invoke-WebRequest' -ModuleName 'HaloAPI' -MockWith { + [pscustomobject]@{ Content = '{"tickets":[{"id":1},{"id":2}]}' } + } + + InModuleScope 'HaloAPI' { + $Result = Invoke-HaloRequest -Method 'GET' -Fragment 'tickets' -ExpandProperty 'tickets' + + $Result.Count | Should -Be 2 + $Result[0].id | Should -Be 1 + $Result[1].id | Should -Be 2 + } + + Should -Invoke -CommandName 'Invoke-WebRequest' -ModuleName 'HaloAPI' -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://example.halo/api/tickets' + } + } + It 'returns the raw web response when RawResult is specified' { Mock -CommandName 'Invoke-WebRequest' -ModuleName 'HaloAPI' -MockWith { [pscustomobject]@{ Content = '{"ok":true}'; StatusCode = 200 } From d42a1e3c484d48c09907baa1dcfbf675ee21e506 Mon Sep 17 00:00:00 2001 From: Mikey O'Toole Date: Mon, 1 Jun 2026 12:55:13 +0100 Subject: [PATCH 6/9] Add custom table data management functions and tests --- HaloAPI.psd1 | 3 ++ Public/New/New-HaloCustomTableData.ps1 | 26 +++++++++++ Public/Remove/Remove-HaloCustomTableData.ps1 | 49 ++++++++++++++++++++ Public/Set/Set-HaloCustomTableData.ps1 | 26 +++++++++++ Tests/HaloAPI.Unit.Tests.ps1 | 44 ++++++++++++++++++ 5 files changed, 148 insertions(+) create mode 100644 Public/New/New-HaloCustomTableData.ps1 create mode 100644 Public/Remove/Remove-HaloCustomTableData.ps1 create mode 100644 Public/Set/Set-HaloCustomTableData.ps1 diff --git a/HaloAPI.psd1 b/HaloAPI.psd1 index 9cd02b1..cf29968 100644 --- a/HaloAPI.psd1 +++ b/HaloAPI.psd1 @@ -148,6 +148,7 @@ 'New-HaloCustomField', 'New-HaloCustomFieldBatch', 'New-HaloCustomTable', + 'New-HaloCustomTableData', 'New-HaloDashboard', 'New-HaloDistributionList', 'New-HaloDistributionListMember', @@ -210,6 +211,7 @@ 'Remove-HaloContract', 'Remove-HaloCRMNote', 'Remove-HaloCustomField', + 'Remove-HaloCustomTableData', 'Remove-HaloDashboard', 'Remove-HaloDistributionList', 'Remove-HaloDistributionListMember', @@ -247,6 +249,7 @@ 'Set-HaloContract', 'Set-HaloCRMNote', 'Set-HaloCustomButton', + 'Set-HaloCustomTableData', 'Set-HaloDashboard', 'Set-HaloDistributionList', 'Set-HaloFAQList', diff --git a/Public/New/New-HaloCustomTableData.ps1 b/Public/New/New-HaloCustomTableData.ps1 new file mode 100644 index 0000000..5334438 --- /dev/null +++ b/Public/New/New-HaloCustomTableData.ps1 @@ -0,0 +1,26 @@ +function New-HaloCustomTableData { + <# + .SYNOPSIS + Creates custom table data via the Halo API. + .DESCRIPTION + Function to send a custom table data creation request to the Halo API using the customtable endpoint. + .OUTPUTS + Outputs an object containing the response from the web request. + #> + [CmdletBinding( SupportsShouldProcess = $True )] + [OutputType([Object[]])] + param ( + # Object or array of objects containing properties and values used to create custom table data records. + # Use the Halo CustomTable payload shape (for example id with _add_rows). + [Parameter( Mandatory = $True, ValueFromPipeline )] + [Object[]]$CustomTableData + ) + Invoke-HaloPreFlightCheck + try { + if ($PSCmdlet.ShouldProcess($CustomTableData -is [Array] ? 'Custom Table Data' : 'Custom Table Data', 'Create')) { + New-HaloPOSTRequest -Object $CustomTableData -Endpoint 'customtable' + } + } catch { + New-HaloError -ErrorRecord $_ + } +} \ No newline at end of file diff --git a/Public/Remove/Remove-HaloCustomTableData.ps1 b/Public/Remove/Remove-HaloCustomTableData.ps1 new file mode 100644 index 0000000..579a234 --- /dev/null +++ b/Public/Remove/Remove-HaloCustomTableData.ps1 @@ -0,0 +1,49 @@ +function Remove-HaloCustomTableData { + <# + .SYNOPSIS + Removes custom table data from the Halo API. + .DESCRIPTION + Deletes custom table data from Halo by posting a CustomTable payload with delete flags. + .OUTPUTS + A powershell object containing the response. + #> + [cmdletbinding( SupportsShouldProcess = $True, ConfirmImpact = 'High' )] + [OutputType([Object])] + Param( + # The custom table ID. + [Parameter( Mandatory = $True )] + [int64]$CustomTableID, + # Deletes all data for the custom table. + [Parameter()] + [switch]$AllData, + # Optional start date for data deletion. + [Parameter()] + [datetime]$StartDate, + # Optional end date for data deletion. + [Parameter()] + [datetime]$EndDate + ) + Invoke-HaloPreFlightCheck + try { + if (-not $AllData -and (-not $PSBoundParameters.ContainsKey('StartDate') -or -not $PSBoundParameters.ContainsKey('EndDate'))) { + throw 'Specify -AllData or provide both -StartDate and -EndDate.' + } + + $Payload = @{ + id = $CustomTableID + _delete_data = $True + } + + if (-not $AllData) { + $Payload['_delete_data_start_date'] = $StartDate + $Payload['_delete_data_end_date'] = $EndDate + } + + if ($PSCmdlet.ShouldProcess(('Custom Table ''{0}'' data' -f $CustomTableID), 'Delete')) { + $CustomTableDataResults = New-HaloPOSTRequest -Object ([pscustomobject]$Payload) -Endpoint 'customtable' + Return $CustomTableDataResults + } + } catch { + New-HaloError -ErrorRecord $_ + } +} \ No newline at end of file diff --git a/Public/Set/Set-HaloCustomTableData.ps1 b/Public/Set/Set-HaloCustomTableData.ps1 new file mode 100644 index 0000000..f6aee77 --- /dev/null +++ b/Public/Set/Set-HaloCustomTableData.ps1 @@ -0,0 +1,26 @@ +function Set-HaloCustomTableData { + <# + .SYNOPSIS + Updates custom table data via the Halo API. + .DESCRIPTION + Function to send a custom table data update request to the Halo API using the customtable endpoint. + .OUTPUTS + Outputs an object containing the response from the web request. + #> + [CmdletBinding( SupportsShouldProcess = $True )] + [OutputType([Object[]])] + param ( + # Object or array of objects containing properties and values used to update custom table data records. + # Use the Halo CustomTable payload shape (for example id with rows). + [Parameter( Mandatory = $True, ValueFromPipeline )] + [Object[]]$CustomTableData + ) + Invoke-HaloPreFlightCheck + try { + if ($PSCmdlet.ShouldProcess($CustomTableData -is [Array] ? 'Custom Table Data' : 'Custom Table Data', 'Update')) { + New-HaloPOSTRequest -Object $CustomTableData -Endpoint 'customtable' + } + } catch { + New-HaloError -ErrorRecord $_ + } +} \ No newline at end of file diff --git a/Tests/HaloAPI.Unit.Tests.ps1 b/Tests/HaloAPI.Unit.Tests.ps1 index adede88..5c0db89 100644 --- a/Tests/HaloAPI.Unit.Tests.ps1 +++ b/Tests/HaloAPI.Unit.Tests.ps1 @@ -4925,6 +4925,50 @@ Describe 'New-HaloCustomTable' { } } +Describe 'CustomTableData' { + BeforeAll { + Mock -CommandName 'Invoke-HaloPreFlightCheck' -ModuleName 'HaloAPI' -MockWith {} + Mock -CommandName 'New-HaloError' -ModuleName 'HaloAPI' -MockWith {} + } + + It 'posts new custom table data to the customtable endpoint' { + Mock -CommandName 'New-HaloPOSTRequest' -ModuleName 'HaloAPI' -MockWith { return @{} } + + New-HaloCustomTableData -CustomTableData ([pscustomobject]@{ id = 1; _add_rows = @(@{ name = 'Alpha' }) }) -Confirm:$false + + Should -Invoke -CommandName 'New-HaloPOSTRequest' -ModuleName 'HaloAPI' -ParameterFilter { + $Endpoint -eq 'customtable' + } -Times 1 -Exactly + } + + It 'updates custom table data via the customtable endpoint' { + Mock -CommandName 'New-HaloPOSTRequest' -ModuleName 'HaloAPI' -MockWith { return @{} } + + Set-HaloCustomTableData -CustomTableData ([pscustomobject]@{ id = 2; rows = @(@{ id = 22; name = 'Beta' }) }) -Confirm:$false + + Should -Invoke -CommandName 'New-HaloPOSTRequest' -ModuleName 'HaloAPI' -ParameterFilter { + $Endpoint -eq 'customtable' + } -Times 1 -Exactly + } + + It 'deletes custom table data via customtable payload flags' { + Mock -CommandName 'New-HaloPOSTRequest' -ModuleName 'HaloAPI' -MockWith { return @{} } + + Remove-HaloCustomTableData -CustomTableID 7 -AllData -Confirm:$false + + Should -Invoke -CommandName 'New-HaloPOSTRequest' -ModuleName 'HaloAPI' -ParameterFilter { + $Endpoint -eq 'customtable' -and $Object.id -eq 7 -and $Object._delete_data -eq $True + } -Times 1 -Exactly + } + + It 'routes invalid delete parameter combinations through New-HaloError' { + Remove-HaloCustomTableData -CustomTableID 7 -Confirm:$false + Should -Invoke -CommandName 'New-HaloError' -ModuleName 'HaloAPI' -ParameterFilter { + $ErrorRecord.Exception.Message -eq 'Specify -AllData or provide both -StartDate and -EndDate.' + } -Times 1 -Exactly + } +} + Describe 'New-HaloDistributionList' { BeforeAll { Mock -CommandName 'Invoke-HaloPreFlightCheck' -ModuleName 'HaloAPI' -MockWith {} From ce304f8ecfda364daf626a373e187d1335a84991 Mon Sep 17 00:00:00 2001 From: Mikey O'Toole Date: Mon, 1 Jun 2026 12:58:08 +0100 Subject: [PATCH 7/9] Prepare 1.24.0-beta1 release --- CHANGELOG.md | 6 ++++++ HaloAPI.psd1 | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1acd5c9..d3b474a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ If you contributed one of these and there's no credit in the line PR to add it or let me know! +## 2026-06-01 - Version 1.24.0-beta1 + +* Add `New-HaloCustomTableData`, `Set-HaloCustomTableData`, and `Remove-HaloCustomTableData` for managing custom table row data through the documented `CustomTable` API surface. +* Improve `Invoke-HaloRequest` with direct request parameters, fragment normalization, method validation, and `ExpandProperty` response extraction. +* Add `-ExcludeSLA` to `Get-HaloTicket`. + ## 2026-04-30 - Version 1.23.3 * Fix release packaging layout so `Public`, `Private`, `Classes`, and `Data` remain wrapped as top-level folders in module output. diff --git a/HaloAPI.psd1 b/HaloAPI.psd1 index cf29968..6a0db19 100644 --- a/HaloAPI.psd1 +++ b/HaloAPI.psd1 @@ -12,7 +12,7 @@ RootModule = '.\HaloAPI.psm1' # Version number of this module. - ModuleVersion = '1.23.3' + ModuleVersion = '1.24.0' # Supported PSEditions CompatiblePSEditions = @('Core') @@ -319,10 +319,10 @@ IconUri = 'https://3c3br937rz386088k2z3qqdi-wpengine.netdna-ssl.com/wp-content/uploads/2020/04/HaloIcon-300x300.png' # ReleaseNotes of this module - ReleaseNotes = 'https://github.com/homotechsual/HaloAPI/releases/tag/1.23.3' + ReleaseNotes = 'https://github.com/homotechsual/HaloAPI/releases/tag/v1.24.0-beta1' # Prerelease string of this module - # Prerelease = 'Beta1' + Prerelease = 'beta1' # Flag to indicate whether the module requires explicit user acceptance for install/update/save # RequireLicenceAcceptance = $false From 449408b9b7af0bb3d3c8902618fc37dae9be8689 Mon Sep 17 00:00:00 2001 From: Mikey O'Toole Date: Mon, 22 Jun 2026 20:29:12 +0100 Subject: [PATCH 8/9] Fix Invoke-HaloRequest boolean leak and Connect-HaloAPI Azure Key Vault integration (#85) --- Public/Connect-HaloAPI.ps1 | 44 ++++++++++++++++++---------- Public/Invoke/Invoke-HaloRequest.ps1 | 2 +- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/Public/Connect-HaloAPI.ps1 b/Public/Connect-HaloAPI.ps1 index fd068e5..4b04ec3 100644 --- a/Public/Connect-HaloAPI.ps1 +++ b/Public/Connect-HaloAPI.ps1 @@ -18,22 +18,22 @@ function Connect-HaloAPI { )] [OutputType([System.Void])] Param ( - # The URL of the Halo instance to connect to. + # The URL of the Halo instance to connect to. Not required if UseKeyVault is set to $true. [Parameter( ParameterSetName = 'Client Credentials', - Mandatory = $True + Mandatory = $False )] [URI]$URL, - # The Client ID for the application configured in Halo. + # The Client ID for the application configured in Halo. Not required if UseKeyVault is set to $true. [Parameter( ParameterSetName = 'Client Credentials', - Mandatory = $True + Mandatory = $False )] [String]$ClientID, - # The Client Secret for the application configured in Halo. + # The Client Secret for the application configured in Halo. Not required if UseKeyVault is set to $true. [Parameter( ParameterSetName = 'Client Credentials', - Mandatory = $True + Mandatory = $False )] [String]$ClientSecret, # The API scopes to request, if this isn't passed the scope is assumed to be "all". Pass a string or array of strings. Limited by the scopes granted to the application in Halo. @@ -70,12 +70,12 @@ function Connect-HaloAPI { ParameterSetName = 'Client Credentials' )] [bool]$SaveToKeyVault = $False, - # The object ID of the Managed Identity or Service Principal. + # When set to $true, uses a Managed Identity to connect to Azure Key Vault instead of interactive login. [Parameter( ParameterSetName = 'Client Credentials', Mandatory = $False )] - [String]$Identity, + [Switch]$UseManagedIdentity, # The maximum number of times to retry requests before giving up. [Parameter( ParameterSetName = 'Client Credentials', @@ -89,32 +89,44 @@ function Connect-HaloAPI { )] [Switch]$NoConfirm ) + # Validate that required parameters are provided when not using KeyVault + if (-not $UseKeyVault -and -not $SaveToKeyVault) { + if ([string]::IsNullOrWhiteSpace($URL)) { + throw 'URL parameter is required when UseKeyVault is not specified.' + } + if ([string]::IsNullOrWhiteSpace($ClientID)) { + throw 'ClientID parameter is required when UseKeyVault is not specified.' + } + if ([string]::IsNullOrWhiteSpace($ClientSecret)) { + throw 'ClientSecret parameter is required when UseKeyVault is not specified.' + } + } if ($UseKeyVault) { # If the Identity parameter is specified, use it to connect. # Otherwise, fall back to interactive login. - if ($Identity) { + if ($UseManagedIdentity) { Connect-AzAccount -Identity } else { Connect-AzAccount } - $URL = (Get-AzKeyVaultSecret -VaultName $VaultName -Name ('{0}_URL' -f $SecretName)).SecretValueText - $ClientID = (Get-AzKeyVaultSecret -VaultName $VaultName -Name ('{0}_ClientID' -f $SecretName)).SecretValueText - $ClientSecret = (Get-AzKeyVaultSecret -VaultName $VaultName -Name ('{0}_ClientSecret' -f $SecretName)).SecretValueText + $URL = Get-AzKeyVaultSecret -VaultName $VaultName -Name ('{0}-URL' -f $SecretName) -AsPlainText + $ClientID = Get-AzKeyVaultSecret -VaultName $VaultName -Name ('{0}-ClientID' -f $SecretName) -AsPlainText + $ClientSecret = Get-AzKeyVaultSecret -VaultName $VaultName -Name ('{0}-ClientSecret' -f $SecretName) -AsPlainText } elseif ($SaveToKeyVault) { # Save the URL, ClientID, and ClientSecret to the Azure Key Vault. - if ($Identity) { + if ($UseManagedIdentity) { Connect-AzAccount -Identity } else { Connect-AzAccount } $URL_Secret = ConvertTo-SecureString -String $URL -AsPlainText -Force - Set-AzKeyVaultSecret -VaultName $VaultName -Name ('{0}_URL' -f $SecretName) -SecretValue $URL_Secret + Set-AzKeyVaultSecret -VaultName $VaultName -Name ('{0}-URL' -f $SecretName) -SecretValue $URL_Secret $ClientID_Secret = ConvertTo-SecureString -String $ClientID -AsPlainText -Force - Set-AzKeyVaultSecret -VaultName $VaultName -Name ('{0}_ClientID' -f $SecretName) -SecretValue $ClientID_Secret + Set-AzKeyVaultSecret -VaultName $VaultName -Name ('{0}-ClientID' -f $SecretName) -SecretValue $ClientID_Secret $ClientSecret_Secret = ConvertTo-SecureString -String $ClientSecret -AsPlainText -Force - Set-AzKeyVaultSecret -VaultName $VaultName -Name ('{0}_ClientSecret' -f $SecretName) -SecretValue $ClientSecret_Secret + Set-AzKeyVaultSecret -VaultName $VaultName -Name ('{0}-ClientSecret' -f $SecretName) -SecretValue $ClientSecret_Secret } # Convert scopes to space separated string if it's an array. diff --git a/Public/Invoke/Invoke-HaloRequest.ps1 b/Public/Invoke/Invoke-HaloRequest.ps1 index 48b9042..8b66ea3 100644 --- a/Public/Invoke/Invoke-HaloRequest.ps1 +++ b/Public/Invoke/Invoke-HaloRequest.ps1 @@ -94,7 +94,7 @@ function Invoke-HaloRequest { Scopes = $Script:HAPIConnectionInformation.AuthScopes Tenant = $Script:HAPIConnectionInformation.Tenant } - Connect-HaloAPI @ReconnectParameters + $null = Connect-HaloAPI @ReconnectParameters -NoConfirm } if ($null -ne $Script:HAPIAuthToken) { $AuthHeaders = @{ From 42aa57424048a8e59dd98f0096f5ebf3d6e8c96a Mon Sep 17 00:00:00 2001 From: Mikey O'Toole Date: Mon, 22 Jun 2026 20:33:41 +0100 Subject: [PATCH 9/9] Prepare 1.24.0 stable release --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3b474a..9cf7d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,12 @@ If you contributed one of these and there's no credit in the line PR to add it or let me know! -## 2026-06-01 - Version 1.24.0-beta1 +## 2026-06-22 - Version 1.24.0 -* Add `New-HaloCustomTableData`, `Set-HaloCustomTableData`, and `Remove-HaloCustomTableData` for managing custom table row data through the documented `CustomTable` API surface. -* Improve `Invoke-HaloRequest` with direct request parameters, fragment normalization, method validation, and `ExpandProperty` response extraction. +* Add `New-HaloCustomTableData`, `Set-HaloCustomTableData`, and `Remove-HaloCustomTableData` for managing custom table row data through the documented `CustomTable` API surface (closes #83). +* Improve `Invoke-HaloRequest` with direct request parameters, fragment normalization, method validation, and `ExpandProperty` response extraction (closes #84). +* Fix `Invoke-HaloRequest` returning `$true` in results when token is renewed. +* Fix `Connect-HaloAPI` Azure Key Vault integration: use hyphens in secret names (valid for Key Vault), use `-AsPlainText` instead of `.SecretValueText`, and make parameters optional when using KeyVault. Replace `Identity` string parameter with `UseManagedIdentity` switch (closes #85). * Add `-ExcludeSLA` to `Get-HaloTicket`. ## 2026-04-30 - Version 1.23.3