O365EndpointFunctions.psm1

#region Class definitions
# Class EndpointSet definies a dataset returned by the webservice.
# The class stays in this root module so it is parsed directly at import time. That avoids the
# well-known dot-sourcing pitfalls for PowerShell classes (parse order, type resolution). The
# plain functions below are dot-sourced from Private/ and Public/, which is safe because their
# bodies only reference [EndpointSet] at run time, by which point this class is already loaded.
class EndpointSet {
    # The immutable ID number of the endpoint set as returned by the web service. This is the
    # key that the "changes" web method references via endpointSetId. $null for endpoints that
    # do not originate from the service (e.g. custom entries merged in).
    [Nullable[int]]$id

    # The service area that this is part of: Common, Exchange, SharePoint, or Skype.
    [string]$serviceArea

    # The service area that this is part of: Common, Exchange, SharePoint, or Skype.
    [string]$serviceAreaDisplayName
    [string]$protocol
    [string]$uri

    # TCP port for the endpoint set (a single port; the comma-separated list returned by the
    # web service is split into one EndpointSet per port). $null when not set.
    [Nullable[uint16]]$tcpPort

    # UDP port for the endpoint set (a single port). $null when not set.
    [Nullable[uint16]]$udpPort

    # The connectivity category for the endpoint set. Valid values are Optimize, Allow, and Default. Required.
    [string]$category

    # True or False if this endpoint set is routed over ExpressRoute.
    [bool]$expressRoute

    # True if this endpoint set is required to have connectivity for Office 365 to be supported. Omitted if false.
    [bool]$required

    # For optional endpoints, this text describes Office 365 functionality that will be missing if IP addresses or URLs
    # in this endpoint set cannot be accessed at the network layer. Omitted if blank.
    [string]$notes

    # Default (parameterless) constructor.
    EndpointSet() { }

    # Full constructor - populates every property in declaration order.
    EndpointSet(
        [Nullable[int]]$id,
        [string]$serviceArea,
        [string]$serviceAreaDisplayName,
        [string]$protocol,
        [string]$uri,
        [string]$tcpPort,
        [string]$udpPort,
        [string]$category,
        [bool]$expressRoute,
        [bool]$required,
        [string]$notes
    ) {
        $this.id                     = $id
        $this.serviceArea            = $serviceArea
        $this.serviceAreaDisplayName = $serviceAreaDisplayName
        $this.protocol               = $protocol
        $this.uri                    = $uri
        # ports arrive as strings from the REST API, sometimes with surrounding whitespace
        # (e.g. "143, 587" -> " 587"); trim and convert to uint16. Empty values are left
        # unset so the [Nullable[uint16]] property stays $null.
        if (-not [string]::IsNullOrWhiteSpace($tcpPort)) { $this.tcpPort = [uint16]($tcpPort.Trim()) }
        if (-not [string]::IsNullOrWhiteSpace($udpPort)) { $this.udpPort = [uint16]($udpPort.Trim()) }
        $this.category               = $category
        $this.expressRoute           = $expressRoute
        $this.required               = $required
        $this.notes                  = $notes
    }

    # Returns the properties one per line, in declaration order.
    [string] ToString() {
        return @(
            $($this.id)
            $($this.serviceArea)
            $($this.serviceAreaDisplayName)
            $($this.protocol)
            $($this.uri)
            $($this.tcpPort)
            $($this.udpPort)
            $($this.category)
            $($this.expressRoute)
            $($this.required)
            $($this.notes)
        ) -join [Environment]::NewLine
    }

    # Returns all properties as a single CSV line, in declaration order. Every value is
    # wrapped in double quotes; embedded double quotes are doubled ("" per RFC 4180).
    [string] ToCSV() {
        $values = @(
            $this.id
            $this.serviceArea
            $this.serviceAreaDisplayName
            $this.protocol
            $this.uri
            $this.tcpPort
            $this.udpPort
            $this.category
            $this.expressRoute
            $this.required
            $this.notes
        )

        $quoted = foreach ($value in $values) {
            '"{0}"' -f ([string]$value).Replace('"', '""')
        }

        return $quoted -join ','
    }

    # Internal helper that calls Invoke-RestMethod with a timeout, retries transient failures
    # and reports a clear, contextual error when a request cannot be completed. Hidden so it
    # is not part of the public surface of the class.
    hidden static [object] InvokeRestRequest([string]$Uri, [int]$MaximumRetryCount, [int]$RetryIntervalSec, [int]$TimeoutSec) {
        $attempt = 0
        while ($true) {
            $attempt++
            try {
                return Invoke-RestMethod -Uri $Uri -Method Get -TimeoutSec $TimeoutSec -ErrorAction Stop
            }
            catch {
                # Determine the HTTP status code (if the failure carried an HTTP response) so we
                # can tell retryable problems from permanent ones.
                $statusCode = $null
                $response   = $_.Exception.Response
                if ($response) {
                    try { $statusCode = [int]$response.StatusCode } catch { $statusCode = $null }
                }

                # HTTP 429 (Too Many Requests): the endpoints/changes methods are rate limited.
                # A short retry will not help - the service guidance is to wait about an hour or
                # use a new client request ID - so fail fast with an actionable message instead
                # of burning the remaining attempts. (The version method is never rate limited.)
                if ($statusCode -eq 429) {
                    $retryAfter = [EndpointSet]::GetRetryAfter($response)
                    $waitHint = if ($retryAfter) {
                        "Retry after $retryAfter."
                    } else {
                        "Wait about an hour before retrying, or use a new client request ID."
                    }
                    throw ("Request to '{0}' was rate limited (HTTP 429). {1}" -f $Uri, $waitHint)
                }

                # 4xx client errors will not succeed on retry, except 408 (Request Timeout),
                # which is transient.
                $isNonRetryable = ($null -ne $statusCode) -and
                                  ($statusCode -ge 400) -and ($statusCode -lt 500) -and
                                  ($statusCode -ne 408)

                $statusText = if ($null -ne $statusCode) { " (HTTP $statusCode)" } else { "" }

                if ($isNonRetryable -or ($attempt -ge $MaximumRetryCount)) {
                    throw ("Request to '{0}' failed after {1} attempt(s){2}: {3}" -f $Uri, $attempt, $statusText, $_.Exception.Message)
                }

                Write-Warning ("Request to '{0}' failed (attempt {1}/{2}){3}: {4} - retrying in {5}s" -f `
                    $Uri, $attempt, $MaximumRetryCount, $statusText, $_.Exception.Message, $RetryIntervalSec)
                Start-Sleep -Seconds $RetryIntervalSec
            }
        }
        # unreachable: the loop either returns a result or throws
        throw "Request to '$Uri' failed."
    }

    # Overload using the default retry/timeout settings (3 attempts, 2s apart, 30s timeout).
    hidden static [object] InvokeRestRequest([string]$Uri) {
        return [EndpointSet]::InvokeRestRequest($Uri, 3, 2, 30)
    }

    # Best-effort extraction of the Retry-After header from a failed HTTP response. Works with
    # HttpResponseMessage (PowerShell 7) and HttpWebResponse (Windows PowerShell 5.1); returns
    # $null when the header is absent or cannot be read.
    hidden static [string] GetRetryAfter([object]$response) {
        if ($null -eq $response) { return $null }
        try {
            $headers = $response.Headers
            if ($null -eq $headers) { return $null }

            # PowerShell 7: HttpResponseHeaders exposes a typed RetryAfter property
            try {
                if ($headers.RetryAfter) { return $headers.RetryAfter.ToString() }
            } catch { $null = $_ }

            # Windows PowerShell 5.1 (WebHeaderCollection) or a plain collection: string indexer
            try {
                $raw = $headers['Retry-After']
                if ($raw) { return (@($raw) -join ',') }
            } catch { $null = $_ }
        } catch { $null = $_ }
        return $null
    }
}
#endregion Class definitions

#region Load functions
# Dot-source the private helper functions first, then the public cmdlets. Only functions live
# in these files - the class above is intentionally kept in this .psm1.
$privateFunctions = @( Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Private') -Filter '*.ps1' -ErrorAction SilentlyContinue )
$publicFunctions  = @( Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Public')  -Filter '*.ps1' -ErrorAction SilentlyContinue )

foreach ($functionFile in ($privateFunctions + $publicFunctions)) {
    try {
        . $functionFile.FullName
    }
    catch {
        throw ("Failed to import function file '{0}': {1}" -f $functionFile.FullName, $_.Exception.Message)
    }
}

# Export only the public functions. The manifest's FunctionsToExport gates this as well.
if ($publicFunctions) {
    Export-ModuleMember -Function $publicFunctions.BaseName
}
#endregion Load functions