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 } |