Commands/Invoke-AtProto.ps1
function Invoke-AtProto { <# .SYNOPSIS Invokes the AT Protocol .DESCRIPTION Invokes the RESTful methods of the At Protocol. .LINK Connect-AtProto #> [Alias( 'Invoke-AtMethod', # Because Invoke-RestMethod is familiar 'Invoke-AtProtocol' # Because some people prefer longnames )] [CmdletBinding(SupportsShouldProcess)] param( # The URI being invoked. If this is empty, a -NamespaceID is required. [Parameter(ValueFromPipelineByPropertyName)] [Alias('Url')] [uri] $Uri, # The HTTP Method. [Parameter(ValueFromPipelineByPropertyName)] [ValidateSet('GET','DELETE','HEAD','MERGE','OPTIONS','PATCH','POST', 'PUT', 'TRACE')] [string] $Method = "GET", # The Namespace ID. # In the AT Protocol, every request has a NamespaceID. [Parameter(ValueFromPipelineByPropertyName)] [string] $NamespaceID, # Any parameters to the request. # If the HTTP Method is get, these will become QueryParameters # Otherwise, they will become -BodyParameters. [Parameter(ValueFromPipelineByPropertyName)] [Alias('Parameters')] [PSObject] $Parameter, # One or more query parameters. [Parameter(ValueFromPipelineByPropertyName)] [Alias('QueryParameters','Query')] [PSObject] $QueryParameter, # One or more body parameters. [Parameter(ValueFromPipelineByPropertyName)] [Alias('BodyParameters','Body')] [PSObject] $BodyParameter, # The authorization. # This can be a JWT that accesses the protocol. # It can also be a credential. # The username is a handle or email. # The password is the app password. [Parameter(ValueFromPipelineByPropertyName)] [Alias('Authentication','AppPassword')] [PSObject] $Authorization, # The content type of the request. # If not provided, this will be assumed to be application/json. [Parameter(ValueFromPipelineByPropertyName)] [string] $ContentType = 'application/json', # Aliases for parameters. # If these parameters are found, they will be called using their alias instead. [Parameter(ValueFromPipelineByPropertyName)] [PSObject] $ParameterAlias, # The typename of the results. [Parameter(ValueFromPipelineByPropertyName)] [Alias('Decorate','Decoration','TypeName')] [string[]] $PSTypeName, # A set of additional properties to add to an object [Parameter(ValueFromPipelineByPropertyName)] [Collections.IDictionary] $Property, # A list of property names to remove from an object. [Parameter(ValueFromPipelineByPropertyName)] [string[]] $RemoveProperty = @("Password"), # If provided, will expand a given property returned from the REST api. [Parameter(ValueFromPipelineByPropertyName)] [string] $ExpandProperty, # If provided, will decorate the values within a property in the return object. # This allows nested REST properties to work with the PowerShell Extended Type System. [Parameter(ValueFromPipelineByPropertyName)] [Collections.IDictionary] [Alias('TypeNameOfProperty')] $DecorateProperty, # If set, will receive results as a byte array. [Parameter(ValueFromPipelineByPropertyName)] [switch] $AsByte, # If set, will cache results from a request. Only HTTP GET results will be cached. [Parameter(ValueFromPipelineByPropertyName)] [switch] $Cache ) begin { if (-not $script:AtServerSessions) { $script:AtServerSessions = [Ordered]@{} } if (-not $script:AtResponseCache) { $script:AtResponseCache = [Ordered]@{} } $InvokeQueue = [Collections.Queue]::new() $defaultHost = "bsky.social" } process { $myParameters = [Ordered]@{} + $PSBoundParameters if ($myParameters.Contains('Authorization')) { if ($Authorization -is [PSCredential]) { # If the authorization was a credential, let it be a username/app-password $authUri = "https://$(if ($uri.Host) { $uri.host } else {$defaultHost})/xrpx/com.atproto.server.createSession" $authenticationResponse = Invoke-AtProtocol -Uri $authUri -Authorization '' -BodyParameter ([Ordered]@{ identifier = $Authorization.username password = $Authorization.GetNetworkCredential().Password }) if (-not $authenticationResponse) { return } $script:AtServerSessions[$authenticationResponse.handle] = $authenticationResponse $script:AtProtocolAccessJWT = $Authorization = $authenticationResponse.accessJWT $script:AtProtocolRefreshJWT = $Authorization = $authenticationResponse.refreshJWT } elseif ($Authorization -is [SecureString]) { $Authorization = [PSCredential]::new("AnyUserNameWillDo", $Authorization).GetNetworkCredential().Password } if ($Authorization -is [string] -and $Authorization) { if ($Authorization -match '[\.]') { $script:AtProtocolAccessJWT = $Authorization } else { # We only have an app password, error out. Write-Error "Authorization appears to be an app password. Provide a credential with your username as well" return } } } $InvokeSplat = [Ordered]@{} $queryParameters = [Ordered]@{} $BodyParameters = [Ordered]@{} if ($Parameter) { $parameterDictionary = if ($Method -eq 'GET') { $queryParameters } else { $BodyParameters } if ($Parameter -is [Collections.IDictionary]) { $parameter = [PSCustomObject]$Parameter } foreach ($prop in $parameter.psobject.properties) { if ($ParameterAlias.($prop.Name)) { $parameterDictionary[$ParameterAlias.($prop.Name)] = $prop.value } else { $parameterDictionary[$prop.Name] = $prop.value } } } $uri = $myParameters["uri"] if (-not $uri.Host) { $uri = "https://$defaultHost/" } if ($myParameters["NamespaceID"] -and $uri -notmatch '\/xprc\/.+$') { $uri = ("$uri" -replace "/$") + ("/xrpc/$NamespaceID") } $InvokeSplat["Uri"] = if ($Method -eq 'GET' -and $queryParameters.Count) { "${uri}?$( @(foreach ($qp in $queryParameters.GetEnumerator()) { "$($qp.Key)=$($qp.Value)" }) -join '&' )" } else { $uri } $InvokeSplat["Method"] = $Method if ($PSBoundParameters["ContentType"]) { $InvokeSplat["ContentType"] = $PSBoundParameters["ContentType"] } if ($BodyParameters.Count -and $method -notin 'get','options') { if ($BodyParameters["."] -as [byte[]]) { $InvokeSplat["Body"] = $BodyParameters["."] -as [byte[]] } else { $InvokeSplat["Body"] = $BodyParameters | ConvertTo-Json -Depth 100 } } if ($script:AtProtocolAccessJWT -and (-not ($PSBoundParameters["Authorization"] -and -not $Authorization))) { $InvokeSplat.Headers = [Ordered]@{"Authorization"="Bearer $($script:AtProtocolAccessJWT)"} } if (-not $InvokeSplat["ContentType"]) { $InvokeSplat["ContentType"] = "application/json" } $InvokeQueue.Enqueue($InvokeSplat) } end { # Get Invoke-RestMethod $invokeRestMethod = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Invoke-RestMethod', 'Cmdlet') # and all of it's parameters $invokeRestMethodParameters = @($invokeRestMethod.Parameters.Keys | Sort-Object) # and turn that list into one regex. $invokeRestMethodRegex = [Regex]::new("(?>$($invokeRestMethodParameters -join '|'))") $psProperties = @() if ($Property) { $psProperties = @( if ($property -is [Collections.IDictionary]) { foreach ($propKeyValue in $Property.GetEnumerator()) { if ($propKeyValue.Value -as [ScriptBlock[]]) { [PSScriptProperty]::new.Invoke(@($propKeyValue.Key) + $propKeyValue.Value) } else { [PSNoteProperty]::new($propKeyValue.Key, $propKeyValue.Value) } } } else { $property.psobject.properties } ) } $OutputToCache = $null if ($cache -and -not $script:InvokeAtProtoCache) { $script:InvokeAtProtoCache = [Ordered]@{} } # Do everything from here on out in nested [ScriptBlock]. # Why? So that the object pipeline sends objects as they are ready. . { # Walk thru all we were to invoke foreach ($iq in $InvokeQueue.ToArray()) { # create a splat for each $toSplat = [Ordered]@{} # and strip out anything not a parameter to Invoke-RestMethod foreach ($keyToSplat in $iq.Keys -match $invokeRestMethodRegex) { $toSplat[$keyToSplat] = $iq[$keyToSplat] } # If -WhatIf was passed if ($WhatIfPreference) { # strip headers $toSplat.Remove('Headers') $toSplat # and return the splat. continue } if ($cache -and $script:InvokeAtProtoCache[$toSplat.uri]) { $script:InvokeAtProtoCache[$toSplat.uri] } else { # Create a human readable form of the request $methodAndUri = $toSplat.Method, $toSplat.uri -join ' ' # and -Confirm the activity (if they passed -Confirm, that is) if ($psCmdlet.ShouldProcess($methodAndUri)) { Write-Verbose $methodAndUri # If we want bytes back if ($AsByte) { # use Invoke-WebRequest Invoke-WebRequest @toSplat } else { # otherwise, use Invoke-RestMethod. Invoke-RestMethod @toSplat } } } } } | & { process { $in = $_ if ($cache -and $script:InvokeAtProtoCache[$toSplat.uri]) { return $script:InvokeAtProtoCache[$toSplat.uri] } if ($in -eq 'null') { return } if ($AsByte -and $in.Content) { $in.Content } elseif ($ExpandProperty) { if ($in.$ExpandProperty) { return $in.$ExpandProperty } } elseif ($in -isnot [string] -and $( $inProperties = @($in.psobject.properties) $inProperties.Length -eq 1 )) { $inProperties[0].Value } else { $in } } } 2>&1 | & { [CmdletBinding()] param( [Parameter(ValueFromPipeline)] [PSObject] $InputObject ) process { # One more step of the pipeline will unroll each of the values. if ($cache -and $script:InvokeAtProtoCache[$toSplat.uri]) { return $script:InvokeAtProtoCache[$toSplat.uri] } $in = $_ if ($in -is [string]) { return $in } if ($PSTypeName -and # If we have a PSTypeName (to apply formatting) $in -isnot [Management.Automation.ErrorRecord] # and it is not an error (which we do not want to format) ) { $in.PSTypeNames.Clear() # then clear the existing typenames and decorate the object. if ($in.'$type') { $in.PSTypeNames.add($in.'$type') } foreach ($t in $PSTypeName) { $in.PSTypeNames.add($T) } } if ($psProperties) { foreach ($prop in $psProperties) { if ($in.psobject.members[$prop.Name]) { continue } try { $in.PSObject.Members.Add($prop, $true) } catch { $exception = $_ $null = $exception } } } if ($RemoveProperty) { foreach ($propToRemove in $RemoveProperty) { $in.PSObject.Properties.Remove($propToRemove) } } if ($DecorateProperty) { foreach ($kv in $DecorateProperty.GetEnumerator()) { $dotKeys = @($kv.Key -split '\.') $target = $in foreach ($dot in $dotKeys) { $target = $target.$dot } if (-not $target) { continue } foreach ($v in @($target)) { if ($null -eq $v -or -not $v.pstypenames) { continue } $v.pstypenames.clear() foreach ($tn in $kv.Value) { $v.pstypenames.add($tn) } } } } return $in # output the object and we're almost done. } } -OutVariable OutputToCache if ($Cache -and $Method -eq 'Get' -and $OutputToCache -and (-not $script:InvokeAtProtoCache[$toSplat.Uri]) ) { $script:InvokeAtProtoCache[$toSplat.Uri] = $OutputToCache } } } |