Private/Sharing/ConvertTo-MultipartFormBody.ps1
|
function ConvertTo-MultipartFormBody { <# .SYNOPSIS Builds a multipart/form-data request body as raw bytes. .DESCRIPTION Invoke-RestMethod's -Form parameter is PowerShell 6.1+. This module targets 5.1 (the manifest says so, and Windows PowerShell is where most users run it), where -Form does not exist and the call fails outright with a parameter binding error. So the body is assembled here and passed to -Body as a byte array, which both editions send verbatim. Both editions deliberately use this same path rather than -Form on 7 and this on 5.1: a branch that only ever executes on an edition the maintainer cannot run is a branch that rots untested. The body is accumulated in a MemoryStream rather than concatenated as a string. A subtitle file is arbitrary bytes - it may be UTF-16, contain a BOM, or carry a lone 0x00 - and round-tripping those through a [string] would silently rewrite them. Only the headers and text fields are encoded (as UTF-8, matching RFC 7578); the file's own bytes are copied through untouched. .PARAMETER Fields Text form fields, name -> value. Pass an [ordered] dictionary to control the order parts appear in the body. .PARAMETER FilePath Optional file to attach as a binary part. .PARAMETER FileFieldName Form field name for the attached file. Required when -FilePath is given. .OUTPUTS Hashtable with: Body - [byte[]] the encoded request body ContentType - the multipart/form-data content type, carrying the boundary that Body was built with. The two belong together; sending one with a mismatched other yields an empty form server-side. #> [OutputType([hashtable])] [CmdletBinding()] param( [System.Collections.IDictionary] $Fields = @{}, [string] $FilePath, [string] $FileFieldName ) if ($FilePath -and -not $FileFieldName) { throw 'ConvertTo-MultipartFormBody: -FileFieldName is required when -FilePath is supplied.' } # A GUID with no separators cannot collide with any content in the body, so no # scan of the payload for the boundary string is needed. $boundary = [Guid]::NewGuid().ToString('N') $crlf = "`r`n" # Constructed rather than [Encoding]::UTF8 so the BOM-less intent is explicit; # GetBytes never emits a preamble either way. $encoding = [System.Text.UTF8Encoding]::new($false) $stream = [System.IO.MemoryStream]::new() try { $writeText = { param([string] $Text) $bytes = $encoding.GetBytes($Text) $stream.Write($bytes, 0, $bytes.Length) } foreach ($name in $Fields.Keys) { $value = $Fields[$name] if ($null -eq $value) { $value = '' } & $writeText ("--$boundary$crlf" + "Content-Disposition: form-data; name=`"$name`"$crlf$crlf" + "$value$crlf") } if ($FilePath) { $fileName = Split-Path -Path $FilePath -Leaf $fileBytes = [System.IO.File]::ReadAllBytes($FilePath) & $writeText ("--$boundary$crlf" + "Content-Disposition: form-data; name=`"$FileFieldName`"; filename=`"$fileName`"$crlf" + "Content-Type: application/octet-stream$crlf$crlf") $stream.Write($fileBytes, 0, $fileBytes.Length) & $writeText $crlf } & $writeText "--$boundary--$crlf" return @{ Body = $stream.ToArray() ContentType = "multipart/form-data; boundary=$boundary" } } finally { $stream.Dispose() } } |