Skip to content
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

If you contributed one of these and there's no credit in the line PR to add it or let me know!

## 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 (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

* Fix release packaging layout so `Public`, `Private`, `Classes`, and `Data` remain wrapped as top-level folders in module output.
Expand Down
9 changes: 6 additions & 3 deletions HaloAPI.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
RootModule = '.\HaloAPI.psm1'

# Version number of this module.
ModuleVersion = '1.23.3'
ModuleVersion = '1.24.0'

# Supported PSEditions
CompatiblePSEditions = @('Core')
Expand Down Expand Up @@ -148,6 +148,7 @@
'New-HaloCustomField',
'New-HaloCustomFieldBatch',
'New-HaloCustomTable',
'New-HaloCustomTableData',
'New-HaloDashboard',
'New-HaloDistributionList',
'New-HaloDistributionListMember',
Expand Down Expand Up @@ -210,6 +211,7 @@
'Remove-HaloContract',
'Remove-HaloCRMNote',
'Remove-HaloCustomField',
'Remove-HaloCustomTableData',
'Remove-HaloDashboard',
'Remove-HaloDistributionList',
'Remove-HaloDistributionListMember',
Expand Down Expand Up @@ -247,6 +249,7 @@
'Set-HaloContract',
'Set-HaloCRMNote',
'Set-HaloCustomButton',
'Set-HaloCustomTableData',
'Set-HaloDashboard',
'Set-HaloDistributionList',
'Set-HaloFAQList',
Expand Down Expand Up @@ -316,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
Comment on lines +322 to 326
# RequireLicenceAcceptance = $false

Expand Down
44 changes: 28 additions & 16 deletions Public/Connect-HaloAPI.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Comment on lines +73 to 79
[Parameter(
ParameterSetName = 'Client Credentials',
Expand All @@ -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.'
}
}
Comment on lines +92 to +103
if ($UseKeyVault) {
# If the Identity parameter is specified, use it to connect.
# Otherwise, fall back to interactive login.
Comment on lines 105 to 106
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.
Expand Down
3 changes: 3 additions & 0 deletions Public/Get/Get-HaloTicket.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
145 changes: 137 additions & 8 deletions Public/Invoke/Invoke-HaloRequest.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,79 @@ 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.
Comment on lines +19 to +20
.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 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
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.
.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.
#>
[Cmdletbinding()]
[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 )]
[ValidateSet('GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'HEAD', 'OPTIONS')]
[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,
# 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
)
Expand All @@ -23,13 +88,13 @@ 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
$null = Connect-HaloAPI @ReconnectParameters -NoConfirm
Comment on lines 90 to +97
}
if ($null -ne $Script:HAPIAuthToken) {
$AuthHeaders = @{
Expand All @@ -43,19 +108,80 @@ 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
}

Comment on lines +111 to +123
if ($null -ne $Headers) {
$WebRequestParams.Headers = $Headers
}

if ($PSBoundParameters.ContainsKey('Body')) {
$WebRequestParams.Body = $Body
}

if ($PSBoundParameters.ContainsKey('ContentType')) {
$WebRequestParams.ContentType = $ContentType
}

if (-not [string]::IsNullOrWhiteSpace($ExpandProperty)) {
$WebRequestParams.ExpandProperty = $ExpandProperty
}
}
$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) {
$FragmentPath = $WebRequestParams.Fragment
if ($FragmentPath -notmatch '/') {
$FragmentPath = '/api/{0}' -f $FragmentPath
}

$WebRequestParams.Uri = [System.Uri]::new($BaseUri, $FragmentPath).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
}
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
}
$ResponseExpandProperty = $null
if ($WebRequestParams.ContainsKey('ExpandProperty')) {
$ResponseExpandProperty = $WebRequestParams.ExpandProperty
$WebRequestParams.Remove('ExpandProperty') | 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))
Expand All @@ -65,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.'
Expand Down
26 changes: 26 additions & 0 deletions Public/New/New-HaloCustomTableData.ps1
Original file line number Diff line number Diff line change
@@ -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 $_
}
}
Loading
Loading