Private/ResultsAndFiles.ps1

function Resolve-DattoApiResultCollection {
    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        [object]$Body,

        [Parameter()]
        [AllowNull()]
        [string]$ResultProperty
    )

    if ($null -eq $Body) {
        return [pscustomobject]@{ PropertyName = $ResultProperty; Values = [object[]]@(); IsEnvelope = $false }
    }

    if (-not [string]::IsNullOrWhiteSpace($ResultProperty)) {
        $property = $Body.PSObject.Properties[$ResultProperty]
        if ($null -eq $property) {
            throw [System.IO.InvalidDataException]::new("The Datto response did not contain the expected '$ResultProperty' property.")
        }
        return [pscustomobject]@{ PropertyName = $ResultProperty; Values = [object[]]@($property.Value); IsEnvelope = $true }
    }

    foreach ($candidate in @('items', 'clients')) {
        $property = $Body.PSObject.Properties[$candidate]
        if ($null -ne $property) {
            return [pscustomobject]@{ PropertyName = $candidate; Values = [object[]]@($property.Value); IsEnvelope = $true }
        }
    }

    if (($Body -is [System.Collections.IEnumerable]) -and -not ($Body -is [string]) -and -not ($Body -is [System.Collections.IDictionary]) -and -not ($Body -is [pscustomobject])) {
        return [pscustomobject]@{ PropertyName = $null; Values = [object[]]@($Body); IsEnvelope = $false }
    }

    return [pscustomobject]@{ PropertyName = $null; Values = [object[]]@($Body); IsEnvelope = $false }
}

function Get-DattoApiPaginationObject {
    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        [object]$Body
    )

    if ($null -ne $Body -and $Body.PSObject.Properties.Name -contains 'pagination') {
        return $Body.pagination
    }
    return $null
}

function New-DattoApiAggregatedEnvelope {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$FirstBody,

        [Parameter(Mandatory)]
        [string]$ResultProperty,

        [Parameter(Mandatory)]
        [object[]]$Values,

        [Parameter(Mandatory)]
        [int]$FirstPage,

        [Parameter(Mandatory)]
        [int]$LastPage,

        [Parameter(Mandatory)]
        [int]$PagesRequested
    )

    $output = [ordered]@{}
    foreach ($property in $FirstBody.PSObject.Properties) {
        $output[$property.Name] = $property.Value
    }
    $output[$ResultProperty] = $Values
    $output['_dattoAggregation'] = [pscustomobject][ordered]@{
        Aggregated     = $true
        FirstPage      = $FirstPage
        LastPage       = $LastPage
        PagesRequested = $PagesRequested
        ItemCount      = $Values.Count
    }
    return [pscustomobject]$output
}

function Get-DattoApiSpecificationDocument {
    [CmdletBinding()]
    param()

    $path = Join-Path -Path $script:DattoApiModuleRoot -ChildPath 'spec/DattoAPI.openapi.json'
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
        throw [System.IO.FileNotFoundException]::new('The bundled Datto OpenAPI specification could not be found.', $path)
    }
    return (Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop)
}

function Resolve-DattoApiOutputPath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        [string]$DefaultFileName,

        [Parameter(Mandatory)]
        [string]$RequiredExtension,

        [Parameter()]
        [switch]$CreateDirectory
    )

    $candidate = $Path
    $endsWithSeparator = (
        $candidate.EndsWith([string][IO.Path]::DirectorySeparatorChar) -or
        $candidate.EndsWith([string][IO.Path]::AltDirectorySeparatorChar) -or
        $candidate.EndsWith('/') -or
        $candidate.EndsWith('\')
    )
    if ((Test-Path -LiteralPath $candidate -PathType Container) -or $endsWithSeparator) {
        if (-not (Test-Path -LiteralPath $candidate -PathType Container) -and -not $CreateDirectory) {
            throw [System.IO.DirectoryNotFoundException]::new("The destination directory does not exist: $candidate")
        }
        $candidate = Join-Path -Path $candidate -ChildPath $DefaultFileName
    }
    elseif ([string]::IsNullOrWhiteSpace([IO.Path]::GetExtension($candidate))) {
        $candidate = $candidate + $RequiredExtension
    }

    $fullPath = [IO.Path]::GetFullPath($candidate)
    $parent = Split-Path -Path $fullPath -Parent
    if (-not (Test-Path -LiteralPath $parent -PathType Container) -and -not $CreateDirectory) {
        throw [System.IO.DirectoryNotFoundException]::new("The destination directory does not exist: $parent")
    }
    return $fullPath
}

function ConvertTo-DattoApiSafeFileComponent {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Value
    )

    $invalid = @([IO.Path]::GetInvalidFileNameChars()) + @('<', '>', ':', '"', '/', '\', '|', '?', '*')
    $builder = [Text.StringBuilder]::new()
    foreach ($character in $Value.ToCharArray()) {
        if ($invalid -contains $character) { $null = $builder.Append('_') }
        else { $null = $builder.Append($character) }
    }
    return $builder.ToString()
}

function Test-DattoApiByteSequence {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [byte[]]$Bytes,

        [Parameter(Mandatory)]
        [byte[]]$Sequence,

        [Parameter()]
        [switch]$AtEnd
    )

    if ($Bytes.Length -lt $Sequence.Length) {
        return $false
    }

    $offset = 0
    if ($AtEnd) {
        $offset = $Bytes.Length - $Sequence.Length
    }

    for ($index = 0; $index -lt $Sequence.Length; $index++) {
        if ($Bytes[$offset + $index] -ne $Sequence[$index]) {
            return $false
        }
    }
    return $true
}

function Assert-DattoApiImageResponse {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Response,

        [Parameter(Mandatory)]
        [ValidateSet('Png', 'Jpeg')]
        [string]$ImageType
    )

    if ($null -eq $Response.Bytes -or $Response.Bytes.Length -eq 0) {
        throw [System.IO.InvalidDataException]::new('Datto returned an empty screenshot response.')
    }

    switch ($ImageType) {
        'Png' {
            $expectedContentType = 'image/png'
            $validStart = Test-DattoApiByteSequence -Bytes $Response.Bytes -Sequence ([byte[]](137, 80, 78, 71, 13, 10, 26, 10))
            $validEnd = Test-DattoApiByteSequence -Bytes $Response.Bytes -Sequence ([byte[]](0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130)) -AtEnd
        }
        'Jpeg' {
            $expectedContentType = 'image/jpeg'
            $validStart = Test-DattoApiByteSequence -Bytes $Response.Bytes -Sequence ([byte[]](255, 216, 255))
            $validEnd = Test-DattoApiByteSequence -Bytes $Response.Bytes -Sequence ([byte[]](255, 217)) -AtEnd
        }
    }

    if (-not [string]::IsNullOrWhiteSpace([string]$Response.ContentType) -and [string]$Response.ContentType -ine $expectedContentType) {
        throw [System.IO.InvalidDataException]::new("Datto returned content type '$($Response.ContentType)' where '$expectedContentType' was expected.")
    }
    if (-not $validStart -or -not $validEnd) {
        throw [System.IO.InvalidDataException]::new("Datto returned bytes that do not have a valid $ImageType image signature.")
    }
}

function Write-DattoApiFileAtomically {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        [byte[]]$Bytes,

        [Parameter()]
        [switch]$Force
    )

    $directory = Split-Path -Path $Path -Parent
    $fileName = [IO.Path]::GetFileName($Path)
    $temporaryPath = Join-Path -Path $directory -ChildPath ('.{0}.{1}.tmp' -f $fileName, [guid]::NewGuid().ToString('N'))

    try {
        $stream = [IO.FileStream]::new(
            $temporaryPath,
            [IO.FileMode]::CreateNew,
            [IO.FileAccess]::Write,
            [IO.FileShare]::None
        )
        try {
            $stream.Write($Bytes, 0, $Bytes.Length)
            $stream.Flush($true)
        }
        finally {
            $stream.Dispose()
        }

        if ([IO.File]::Exists($Path)) {
            if (-not $Force) {
                throw [System.IO.IOException]::new("The destination file already exists: $Path. Use -Force to overwrite it.")
            }
            [IO.File]::Replace($temporaryPath, $Path, $null)
        }
        else {
            try {
                [IO.File]::Move($temporaryPath, $Path)
            }
            catch [System.IO.IOException] {
                if (-not $Force -or -not [IO.File]::Exists($Path)) {
                    throw
                }
                [IO.File]::Replace($temporaryPath, $Path, $null)
            }
        }
        $temporaryPath = $null
    }
    finally {
        if (-not [string]::IsNullOrWhiteSpace($temporaryPath) -and [IO.File]::Exists($temporaryPath)) {
            [IO.File]::Delete($temporaryPath)
        }
    }
}