InvokeZEP.psm1
|
# Invoke-ZEP # Lokale Sitzungen lesen das Bearer-Token für die hhpberlin-ZEP-Instanz aus dem Windows Credential Manager. # In Azure Automation Runbooks wird das Token aus Get-AutomationPSCredential gelesen. # Version 0.1.0 06.08.2026 by Klaus Kupferschmid (tempero.it GmbH & hhpberlin GmbH) #Requires -Modules @{ ModuleName = 'BetterCredentials'; ModuleVersion = '4.5' } $script:ZEPSettings = [ordered]@{ ServiceUserName = 'ZEP_hhpberlin' BaseUri = 'https://www.zep-online.de/zephhpberlin/next/api/v1' TokenTarget = 'ZEP_hhpberlin_BearerToken' CacheDirectory = (Join-Path $env:LOCALAPPDATA 'InvokeZEP') OfferCacheFileName = 'offers-cache.json' OfferItemCacheFileName = 'offer-items-cache.json' DefaultPageSize = 100 DefaultThrottleDelaySeconds = 0.2 DefaultCacheTtlMinutes = 60 } function Update-ZEPDerivedSettings { $script:ZEP_cache_directory = $script:ZEPSettings.CacheDirectory $script:ZEP_offer_cache_path = Join-Path $script:ZEP_cache_directory $script:ZEPSettings.OfferCacheFileName $script:ZEP_offer_item_cache_path = Join-Path $script:ZEP_cache_directory $script:ZEPSettings.OfferItemCacheFileName } function Get-ZEPConfiguration { [CmdletBinding()] param () return [pscustomobject]@{ ServiceUserName = $script:ZEPSettings.ServiceUserName BaseUri = $script:ZEPSettings.BaseUri TokenTarget = $script:ZEPSettings.TokenTarget CacheDirectory = $script:ZEPSettings.CacheDirectory OfferCacheFileName = $script:ZEPSettings.OfferCacheFileName OfferItemCacheFileName = $script:ZEPSettings.OfferItemCacheFileName DefaultPageSize = $script:ZEPSettings.DefaultPageSize DefaultThrottleDelaySeconds = $script:ZEPSettings.DefaultThrottleDelaySeconds DefaultCacheTtlMinutes = $script:ZEPSettings.DefaultCacheTtlMinutes } } function Set-ZEPConfiguration { [CmdletBinding()] param ( [Parameter(Mandatory = $false)][string]$ServiceUserName, [Parameter(Mandatory = $false)][string]$BaseUri, [Parameter(Mandatory = $false)][string]$TokenTarget, [Parameter(Mandatory = $false)][string]$CacheDirectory, [Parameter(Mandatory = $false)][int]$DefaultPageSize, [Parameter(Mandatory = $false)][double]$DefaultThrottleDelaySeconds, [Parameter(Mandatory = $false)][int]$DefaultCacheTtlMinutes ) if ($PSBoundParameters.ContainsKey('ServiceUserName')) { $script:ZEPSettings.ServiceUserName = $ServiceUserName } if ($PSBoundParameters.ContainsKey('BaseUri')) { $script:ZEPSettings.BaseUri = $BaseUri.TrimEnd('/') } if ($PSBoundParameters.ContainsKey('TokenTarget')) { $script:ZEPSettings.TokenTarget = $TokenTarget } if ($PSBoundParameters.ContainsKey('CacheDirectory')) { $script:ZEPSettings.CacheDirectory = $CacheDirectory } if ($PSBoundParameters.ContainsKey('DefaultPageSize')) { $script:ZEPSettings.DefaultPageSize = $DefaultPageSize } if ($PSBoundParameters.ContainsKey('DefaultThrottleDelaySeconds')) { $script:ZEPSettings.DefaultThrottleDelaySeconds = $DefaultThrottleDelaySeconds } if ($PSBoundParameters.ContainsKey('DefaultCacheTtlMinutes')) { $script:ZEPSettings.DefaultCacheTtlMinutes = $DefaultCacheTtlMinutes } Update-ZEPDerivedSettings return Get-ZEPConfiguration } Update-ZEPDerivedSettings function Initialize-AutomationEnvironment { if ($script:automationEnvironmentInitialized) { return } $script:automationEnvironmentInitialized = $true $script:env_runbook = $false try { if ($PSPrivateMetadata.JobId) { $script:env_runbook = $true } } catch { $script:env_runbook = $false } } function Convert-SecureStringToPlainText { param ( [Parameter(Mandatory = $false)] [Security.SecureString] $SecureString ) if ($null -eq $SecureString) { return '' } $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { if ($bstr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } } function Get-StoredCredentialSafe { param ( [Parameter(Mandatory = $true)] [string] $Target ) $globalErrorCountBefore = $global:Error.Count try { return Find-Credential -Filter $Target -ErrorAction Stop | Select-Object -First 1 } catch { $message = $PSItem.Exception.Message $isExpectedNotFound = ( $message -match 'Element.*(not found|nicht gefunden)' -or $message -match '(Credential|Element).*(not found|nicht gefunden)' ) if ($isExpectedNotFound) { while ($global:Error.Count -gt $globalErrorCountBefore) { $global:Error.RemoveAt(0) } return $null } Write-Warning "Unerwarteter Fehler bei Find-Credential fuer Target '$Target': $message" return $null } } function Get-ZEPBearerToken { Initialize-AutomationEnvironment if ($env_runbook) { $automationCredentialCommand = Get-Command -Name Get-AutomationPSCredential -ErrorAction SilentlyContinue if (-not $automationCredentialCommand) { throw 'Get-AutomationPSCredential ist in dieser Runbook-Umgebung nicht verfuegbar.' } try { $credential = Get-AutomationPSCredential -Name $script:ZEPSettings.ServiceUserName -ErrorAction Stop } catch { throw "AutomationPSCredential mit dem Namen $($script:ZEPSettings.ServiceUserName) konnte nicht gelesen werden." } if (-not $credential) { throw "AutomationPSCredential mit dem Namen $($script:ZEPSettings.ServiceUserName) wurde nicht gefunden." } return Convert-SecureStringToPlainText -SecureString $credential.Password } $storedCredential = Get-StoredCredentialSafe -Target $script:ZEPSettings.TokenTarget if ($storedCredential) { return Convert-SecureStringToPlainText -SecureString $storedCredential.Password } Write-Host 'ZEP API-Token wird benoetigt' -ForegroundColor Yellow $credential = Microsoft.PowerShell.Security\Get-Credential -UserName $script:ZEPSettings.ServiceUserName -Message 'Geben Sie den ZEP API-Token ein' if (-not $credential) { throw 'ZEP API-Token wurde nicht eingegeben.' } Set-Credential -Target $script:ZEPSettings.TokenTarget -Credential $credential -Type Generic -Persistence Enterprise -Description 'ZEP API token' > $null return Convert-SecureStringToPlainText -SecureString $credential.Password } function Invoke-ZEPRest { [CmdletBinding()] param ( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $false)][string]$Method = 'GET', [Parameter(Mandatory = $false)][hashtable]$Query, [Parameter(Mandatory = $false)]$Body, [Parameter(Mandatory = $false)][int]$RetryCount = 2, [Parameter(Mandatory = $false)][double]$ThrottleDelaySeconds = $script:ZEPSettings.DefaultThrottleDelaySeconds ) $token = Get-ZEPBearerToken $headers = @{ Accept = 'application/json' Authorization = "Bearer $token" } $resourceUri = $script:ZEPSettings.BaseUri.TrimEnd('/') if ($Path) { $resourceUri = "$resourceUri/$($Path.TrimStart('/'))" } $uriBuilder = [System.UriBuilder]::new($resourceUri) if ($Query -and $Query.Count -gt 0) { $queryCollection = [System.Web.HttpUtility]::ParseQueryString([string]::Empty) foreach ($key in $Query.Keys) { $queryCollection[$key] = [string]$Query[$key] } $queryString = $queryCollection.ToString() if (-not [string]::IsNullOrWhiteSpace($queryString)) { $uriBuilder.Query = $queryString } } $validatedUri = [uri]::new($uriBuilder.Uri.AbsoluteUri) $invokeParams = @{ Uri = $validatedUri Method = $Method Headers = $headers ErrorAction = 'Stop' } if ($Body) { $invokeParams.ContentType = 'application/json' $invokeParams.Body = $Body | ConvertTo-Json -Depth 20 } for ($attempt = 0; $attempt -le $RetryCount; $attempt++) { try { if ($ThrottleDelaySeconds -gt 0) { Start-Sleep -Milliseconds ([int]($ThrottleDelaySeconds * 1000)) } return Invoke-RestMethod @invokeParams } catch { $statusCode = $null if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { $statusCode = [int]$_.Exception.Response.StatusCode } if ($attempt -lt $RetryCount -and ($statusCode -eq 429 -or $statusCode -eq 503)) { $backoffSeconds = [Math]::Min(10, [Math]::Pow(2, $attempt + 1)) Start-Sleep -Seconds $backoffSeconds continue } throw } } } function ConvertTo-ZEPObject { param ( [Parameter(ValueFromPipeline = $true)] $InputObject ) process { if ($null -eq $InputObject) { return $null } if ($InputObject -is [string] -or $InputObject -is [bool] -or $InputObject -is [int] -or $InputObject -is [long] -or $InputObject -is [double] -or $InputObject -is [decimal] -or $InputObject -is [datetime]) { return $InputObject } if ($InputObject -is [System.Collections.IDictionary]) { $result = [pscustomobject]::new() foreach ($key in $InputObject.Keys) { $value = $InputObject[$key] if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { $value = @($value | ForEach-Object { ConvertTo-ZEPObject -InputObject $_ }) } elseif ($value -is [pscustomobject] -or $value -is [hashtable]) { $value = ConvertTo-ZEPObject -InputObject $value } $result | Add-Member -NotePropertyName $key -NotePropertyValue $value } return $result } if ($InputObject -is [System.Collections.IEnumerable] -and -not ($InputObject -is [string])) { return @($InputObject | ForEach-Object { ConvertTo-ZEPObject -InputObject $_ }) } $result = [pscustomobject]::new() foreach ($property in $InputObject.PSObject.Properties) { $value = $property.Value if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { $value = @($value | ForEach-Object { ConvertTo-ZEPObject -InputObject $_ }) } elseif ($value -is [pscustomobject] -or $value -is [hashtable]) { $value = ConvertTo-ZEPObject -InputObject $value } $result | Add-Member -NotePropertyName $property.Name -NotePropertyValue $value } return $result } } function Get-ZEPResponseItems { param ( [Parameter(Mandatory = $false)]$Payload ) if ($null -eq $Payload) { return @() } if ($Payload -is [System.Collections.IEnumerable] -and -not ($Payload -is [string]) -and -not ($Payload -is [System.Collections.IDictionary])) { return @($Payload) } if ($Payload.PSObject.Properties.Name -contains 'data') { return Get-ZEPResponseItems -Payload $Payload.data } if ($Payload.PSObject.Properties.Name -contains 'items') { return Get-ZEPResponseItems -Payload $Payload.items } if ($Payload.PSObject.Properties.Name -contains 'offers') { return Get-ZEPResponseItems -Payload $Payload.offers } return @($Payload) } function Get-ZEPResponseTotal { param ( [Parameter(Mandatory = $false)]$Payload ) if ($null -eq $Payload) { return $null } if ($Payload -is [System.Collections.IDictionary]) { if ($Payload.Contains('meta')) { $meta = $Payload['meta'] if ($meta -is [System.Collections.IDictionary] -and $meta.Contains('total')) { return [int]$meta['total'] } if ($meta -and $meta.PSObject.Properties.Name -contains 'total') { return [int]$meta.total } } if ($Payload.Contains('data')) { return Get-ZEPResponseTotal -Payload $Payload.data } if ($Payload.Contains('total')) { return [int]$Payload.total } return $null } if ($Payload.PSObject.Properties.Name -contains 'meta') { $meta = $Payload.meta if ($meta -and $meta.PSObject.Properties.Name -contains 'total') { return [int]$meta.total } } if ($Payload.PSObject.Properties.Name -contains 'data') { return Get-ZEPResponseTotal -Payload $Payload.data } if ($Payload.PSObject.Properties.Name -contains 'total') { return [int]$Payload.total } return $null } function Test-ZEPOffersCacheNeedsRefresh { [CmdletBinding()] param ( [Parameter(Mandatory = $false)]$CacheObject, [Parameter(Mandatory = $false)][int]$CurrentTotal, [Parameter(Mandatory = $false)][int]$CacheTtlMinutes = $script:ZEPSettings.DefaultCacheTtlMinutes ) if (-not $CacheObject) { return $true } if ($PSBoundParameters.ContainsKey('CurrentTotal')) { $totalChanged = [int]$CacheObject.total -ne [int]$CurrentTotal if ($totalChanged) { return $true } $cachedItems = @($CacheObject.items) $itemCount = @($cachedItems).Count return $itemCount -ne [int]$CurrentTotal } $cachedTotal = $null if ($CacheObject.PSObject.Properties.Name -contains 'total' -and $null -ne $CacheObject.total) { $cachedTotal = [int]$CacheObject.total } if ($null -eq $cachedTotal) { return $true } try { $validationResponse = Invoke-ZEPRest -Path 'offers' -Query @{ limit = 1; page = 1 } -ThrottleDelaySeconds 0 $validationTotal = Get-ZEPResponseTotal -Payload $validationResponse if ($null -eq $validationTotal) { return $false } if ([int]$validationTotal -ne [int]$cachedTotal) { return $true } $cachedItems = @($CacheObject.items) return @($cachedItems).Count -ne [int]$validationTotal } catch { return $false } } function Get-ZEPDefaultOfferCachePath { return $script:ZEP_offer_cache_path } function Get-ZEPDefaultOfferItemCachePath { return $script:ZEP_offer_item_cache_path } function Initialize-ZEPCacheStore { param ( [Parameter(Mandatory = $false)][string]$Path = $script:ZEP_offer_cache_path ) $directory = Split-Path -Parent $Path if (-not [string]::IsNullOrWhiteSpace($directory) -and -not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } } function ConvertTo-ZEPFingerprint { param ( [Parameter(Mandatory = $true)]$Record ) $canonicalJson = $Record | ConvertTo-Json -Depth 20 -Compress $sha256 = [System.Security.Cryptography.SHA256]::Create() $bytes = [System.Text.Encoding]::UTF8.GetBytes($canonicalJson) $hash = $sha256.ComputeHash($bytes) return [System.BitConverter]::ToString($hash).Replace('-', '').ToLowerInvariant() } function Read-ZEPOfferCache { param ( [Parameter(Mandatory = $false)][string]$Path = $script:ZEP_offer_cache_path ) if (-not (Test-Path -LiteralPath $Path)) { return $null } try { return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -Depth 100 } catch { return $null } } function Write-ZEPOfferCache { param ( [Parameter(Mandatory = $true)]$CacheObject, [Parameter(Mandatory = $false)][string]$Path = $script:ZEP_offer_cache_path ) Initialize-ZEPCacheStore -Path $Path $CacheObject | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $Path -Encoding UTF8 } function Get-ZEPOffer { [CmdletBinding()] param ( [Parameter(Mandatory = $true)][int]$Id, [Parameter(Mandatory = $false)][switch]$UseCache, [Parameter(Mandatory = $false)][switch]$Refresh, [Parameter(Mandatory = $false)][int]$CacheTtlMinutes = $script:ZEPSettings.DefaultCacheTtlMinutes, [Parameter(Mandatory = $false)][string]$CachePath = $script:ZEP_offer_cache_path ) $shouldUseCache = $true if ($PSBoundParameters.ContainsKey('UseCache')) { $shouldUseCache = $UseCache.IsPresent } if ($Refresh) { $shouldUseCache = $false } $cache = $null if ($shouldUseCache) { $cache = Read-ZEPOfferCache -Path $CachePath if ($cache -and $cache.items) { $cachedEntry = $cache.items | Where-Object { [int]$_.id -eq $Id } | Select-Object -First 1 if ($cachedEntry -and $cachedEntry.data) { return $cachedEntry.data } } } $response = Invoke-ZEPRest -Path "offers/$Id" $items = Get-ZEPResponseItems -Payload $response $record = $items | Select-Object -First 1 if ($record) { $normalizedRecord = ConvertTo-ZEPObject -InputObject $record if ($shouldUseCache) { $cache = Read-ZEPOfferCache -Path $CachePath if (-not $cache) { $cache = [pscustomobject]@{ version = 1; generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); total = $null; items = @() } } $existingEntries = @($cache.items) $updatedEntries = @() $updated = $false foreach ($entry in $existingEntries) { if ([int]$entry.id -eq $Id) { $updatedEntries += [pscustomobject]@{ id = $Id data = $normalizedRecord fingerprint = ConvertTo-ZEPFingerprint -Record $normalizedRecord } $updated = $true } else { $updatedEntries += $entry } } if (-not $updated) { $updatedEntries += [pscustomobject]@{ id = $Id data = $normalizedRecord fingerprint = ConvertTo-ZEPFingerprint -Record $normalizedRecord } } $cache.items = @($updatedEntries) $cache.generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') Write-ZEPOfferCache -CacheObject $cache -Path $CachePath } return $normalizedRecord } return $null } function Get-ZEPOffers { [CmdletBinding()] param ( [Parameter(Mandatory = $false)][switch]$UseCache, [Parameter(Mandatory = $false)][switch]$Refresh, [Parameter(Mandatory = $false)][switch]$ValidateCache, [Parameter(Mandatory = $false)][int]$PageSize = $script:ZEPSettings.DefaultPageSize, [Parameter(Mandatory = $false)][int]$CacheTtlMinutes = $script:ZEPSettings.DefaultCacheTtlMinutes, [Parameter(Mandatory = $false)][string]$CachePath = $script:ZEP_offer_cache_path ) $shouldUseCache = $true if ($PSBoundParameters.ContainsKey('UseCache')) { $shouldUseCache = $UseCache.IsPresent } if ($Refresh) { $shouldUseCache = $false } $shouldValidateCache = $true if ($PSBoundParameters.ContainsKey('ValidateCache')) { $shouldValidateCache = $ValidateCache.IsPresent } $cacheInvalidReason = $null if ($shouldUseCache) { $cache = Read-ZEPOfferCache -Path $CachePath if ($cache -and $cache.items) { $cachedTotal = $null if ($cache.PSObject.Properties.Name -contains 'total' -and $null -ne $cache.total) { $cachedTotal = [int]$cache.total } $needsRefresh = $false if ($shouldValidateCache) { try { $validationResponse = Invoke-ZEPRest -Path 'offers' -Query @{ limit = 1; page = 1 } -ThrottleDelaySeconds 0 $validationTotal = Get-ZEPResponseTotal -Payload $validationResponse if ($null -ne $validationTotal -and $null -ne $cachedTotal -and [int]$validationTotal -ne [int]$cachedTotal) { $needsRefresh = $true $cacheInvalidReason = "Cache ungültig: Gesamtanzahl der Angebote hat sich geändert (cached=$cachedTotal, aktuell=$validationTotal)." } } catch { $needsRefresh = $false } } if (-not $needsRefresh) { return @($cache.items | ForEach-Object { $_.data }) } if (-not $cacheInvalidReason) { $cacheInvalidReason = 'Cache wird nicht verwendet, weil eine Validierung oder Aktualisierung erforderlich ist.' } Write-Host $cacheInvalidReason -ForegroundColor Yellow } else { $cacheInvalidReason = 'Cache ist leer oder enthält keine gültigen Einträge.' Write-Host $cacheInvalidReason -ForegroundColor Yellow } } $allRecords = [System.Collections.Generic.List[object]]::new() $page = 1 $collectionTotal = $null while ($true) { $query = @{ limit = $PageSize; page = $page } $response = Invoke-ZEPRest -Path 'offers' -Query $query if ($null -eq $collectionTotal) { $collectionTotal = Get-ZEPResponseTotal -Payload $response } $pageItems = Get-ZEPResponseItems -Payload $response if (-not $pageItems -or $pageItems.Count -eq 0) { break } foreach ($pageItem in $pageItems) { $allRecords.Add((ConvertTo-ZEPObject -InputObject $pageItem)) } $lastPage = $null if ($response.PSObject.Properties.Name -contains 'meta') { $meta = $response.meta if ($meta -and $meta.PSObject.Properties.Name -contains 'last_page') { $lastPage = [int]$meta.last_page } } if ($null -ne $lastPage -and $page -ge $lastPage) { break } if ($pageItems.Count -lt $PageSize -and $null -eq $lastPage) { break } Write-Host ("Fetched page $page with $($pageItems.Count) items. Total fetched so far: $($allRecords.Count).") $page++ } $cacheObject = [pscustomobject]@{ version = 1 generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') total = if ($null -ne $collectionTotal) { [int]$collectionTotal } else { $allRecords.Count } items = @() } foreach ($record in $allRecords) { $cacheObject.items += [pscustomobject]@{ id = $record.id data = $record fingerprint = ConvertTo-ZEPFingerprint -Record $record } } Write-ZEPOfferCache -CacheObject $cacheObject -Path $CachePath return @($allRecords) } function Get-ZEPOfferItems { [CmdletBinding()] param ( [Parameter(Mandatory = $true)][int]$OfferId, [Parameter(Mandatory = $false)][int]$PageSize = $script:ZEPSettings.DefaultPageSize, [Parameter(Mandatory = $false)][int]$Page = 1, [Parameter(Mandatory = $false)][string]$CachePath = $script:ZEP_offer_item_cache_path ) $cache = $null if (Test-Path -LiteralPath $CachePath) { $cache = Read-ZEPOfferCache -Path $CachePath } if ($cache -and $cache.items) { $cachedEntry = $cache.items | Where-Object { [int]$_.offer_id -eq $OfferId -and [int]$_.page -eq $Page } | Select-Object -First 1 if ($cachedEntry -and $cachedEntry.data) { return @($cachedEntry.data) } } $response = Invoke-ZEPRest -Path "offers/$OfferId/items" -Query @{ limit = $PageSize; page = $Page } $pageItems = Get-ZEPResponseItems -Payload $response $result = @($pageItems | ForEach-Object { ConvertTo-ZEPObject -InputObject $_ }) if (-not $cache) { $cache = [pscustomobject]@{ version = 1; generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); items = @() } } $cache.items += [pscustomobject]@{ offer_id = $OfferId page = $Page data = $result fingerprint = ConvertTo-ZEPFingerprint -Record $result lastValidatedUtc = (Get-Date).ToUniversalTime().ToString('o') } Write-ZEPOfferCache -CacheObject $cache -Path $CachePath return $result } Export-ModuleMember Get-ZEPConfiguration Export-ModuleMember Set-ZEPConfiguration Export-ModuleMember Invoke-ZEPRest Export-ModuleMember Get-ZEPOffer Export-ModuleMember Get-ZEPOffers Export-ModuleMember Get-ZEPOfferItems Export-ModuleMember Test-ZEPOffersCacheNeedsRefresh |