Private/Invoke-MacadressApi.ps1

function Get-MacadressConfigStore {
    <#
        Internal: the single mutable state bag for the module, kept on the
        module scope so every function sees the same values within a session.
    #>

    if (-not $script:MacadressConfig) {
        $script:MacadressConfig = [ordered]@{
            BaseUri        = 'https://api.macadress.com'
            ApiKey         = $null
            TimeoutSeconds = 30
        }
    }
    $script:MacadressConfig
}

function Resolve-MacadressApiKey {
    <#
        Internal: pick the API key to use, most specific first:
        an explicit -ApiKey, then Set-MacadressConfiguration, then the
        MACADRESS_API_KEY environment variable.
    #>

    param([string] $ApiKey)

    if ($ApiKey) { return $ApiKey }

    $cfg = Get-MacadressConfigStore
    if ($cfg.ApiKey) { return $cfg.ApiKey }

    if ($env:MACADRESS_API_KEY) { return $env:MACADRESS_API_KEY }

    return $null
}

function Assert-MacadressApiKey {
    <#
        Internal: throw a helpful terminating error when a keyed call has
        no key to work with.
    #>

    param(
        [string] $ApiKey,
        [Parameter(Mandatory)] [string] $Operation
    )

    $resolved = Resolve-MacadressApiKey -ApiKey $ApiKey
    if (-not $resolved) {
        throw [System.Management.Automation.PSArgumentException]::new(
            "$Operation needs an API key. Get a free one at https://macadress.com/signup, then run " +
            "'Set-MacadressConfiguration -ApiKey <key>' or set the MACADRESS_API_KEY environment variable."
        )
    }
    $resolved
}

function ConvertTo-MacadressPathSegment {
    <#
        Internal: URL-encode a MAC for use as a path segment, but keep ':'
        literal. The API's router matches the raw colon and does not decode
        '%3A', so an encoded colon is rejected as invalid input. '-', '.'
        and bare hex are already safe; a space-grouped address keeps its
        '%20', which the API does decode.
    #>

    param([string] $Value)

    [uri]::EscapeDataString($Value.Trim()) -replace '%3A', ':'
}

function ConvertTo-MacadressOui {
    <#
        Internal: best-effort 24-bit OUI, "AA:BB:CC", from arbitrary MAC input.
    #>

    param([string] $MacAddress)

    $hex = ($MacAddress -replace '[^0-9A-Fa-f]', '').ToUpperInvariant()
    if ($hex.Length -lt 6) { return $null }
    '{0}:{1}:{2}' -f $hex.Substring(0, 2), $hex.Substring(2, 2), $hex.Substring(4, 2)
}

function Invoke-MacadressApi {
    <#
        Internal: the one place that talks to the API. Builds the URL and
        headers, sends the request, and turns any non-success response into
        a single terminating error carrying the status code, request id and
        raw body. Works on Windows PowerShell 5.1 and PowerShell 7+.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [ValidateSet('GET', 'POST')] [string] $Method,
        [Parameter(Mandatory)] [string] $Path,
        [hashtable] $Query,
        $Body,
        [string] $ApiKey,
        [switch] $Raw   # return the response as text, not parsed
    )

    $cfg = Get-MacadressConfigStore

    $uri = '{0}/{1}' -f $cfg.BaseUri.TrimEnd('/'), $Path.TrimStart('/')
    if ($Query -and $Query.Count) {
        $pairs = foreach ($k in $Query.Keys) {
            if ($null -eq $Query[$k] -or $Query[$k] -eq '') { continue }
            '{0}={1}' -f [uri]::EscapeDataString([string]$k), [uri]::EscapeDataString([string]$Query[$k])
        }
        if ($pairs) { $uri = '{0}?{1}' -f $uri, ($pairs -join '&') }
    }

    $headers = @{ Accept = 'application/json' }
    $key = Resolve-MacadressApiKey -ApiKey $ApiKey
    if ($key) { $headers['Authorization'] = "Bearer $key" }

    $params = @{
        Method          = $Method
        Uri             = $uri
        Headers         = $headers
        TimeoutSec      = $cfg.TimeoutSeconds
        UseBasicParsing = $true
        ErrorAction     = 'Stop'
    }
    if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) {
        $params['Body'] = ($Body | ConvertTo-Json -Depth 10 -Compress)
        $params['ContentType'] = 'application/json'
    }

    Write-Verbose "$Method $uri"

    try {
        $response = Invoke-WebRequest @params
    }
    catch {
        throw (ConvertTo-MacadressError -ErrorRecord $_)
    }

    $content = $response.Content
    if ($Raw) { return $content }

    if ([string]::IsNullOrWhiteSpace($content)) { return $null }
    try {
        return $content | ConvertFrom-Json
    }
    catch {
        return $content
    }
}

function ConvertTo-MacadressError {
    <#
        Internal: normalise the many shapes a failed web call takes across
        PowerShell editions into one Exception with a readable message.
    #>

    param([Parameter(Mandatory)] $ErrorRecord)

    $status = $null
    $bodyText = $null

    $ex = $ErrorRecord.Exception

    # PowerShell 7: HttpResponseException carries a Response with a status.
    if ($ex.PSObject.Properties['Response'] -and $ex.Response) {
        try { $status = [int]$ex.Response.StatusCode }
        catch { Write-Debug "no readable StatusCode on the exception response: $_" }
    }

    # The response body: PS7 puts it on the ErrorDetails, WinPS needs the stream.
    if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
        $bodyText = $ErrorRecord.ErrorDetails.Message
    }
    elseif ($ex -is [System.Net.WebException] -and $ex.Response) {
        try {
            $status = [int]$ex.Response.StatusCode
            $reader = [System.IO.StreamReader]::new($ex.Response.GetResponseStream())
            $bodyText = $reader.ReadToEnd()
            $reader.Dispose()
        }
        catch { Write-Debug "could not read the WebException response stream: $_" }
    }

    $message = $bodyText
    $requestId = $null
    if ($bodyText) {
        try {
            $parsed = $bodyText | ConvertFrom-Json -ErrorAction Stop
            if ($parsed.error) { $message = [string]$parsed.error }
            if ($parsed.request_id) { $requestId = [string]$parsed.request_id }
        }
        catch { Write-Debug 'error body is not JSON; using it verbatim' }
    }
    if (-not $message) { $message = $ex.Message }

    $prefix = switch ($status) {
        400 { 'Invalid MAC address' }
        401 { 'Authentication failed (missing or invalid API key)' }
        404 { 'Not found' }
        429 { 'Rate limit or quota exceeded' }
        default { if ($status) { "API error (HTTP $status)" } else { 'Request failed' } }
    }

    $full = "$prefix`: $message"
    if ($requestId) { $full = "$full (request id $requestId)" }

    $exception = [System.Exception]::new($full, $ex)
    $exception.Data['StatusCode'] = $status
    $exception.Data['RequestId'] = $requestId
    $exception.Data['ResponseBody'] = $bodyText
    $exception
}