src/cmdlets/Get-GraphResourceWithMetadata.ps1

# Copyright 2020, Adam Edwards
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

. (import-script ../metadata/GraphManager)
. (import-script Get-GraphUriInfo)
. (import-script ../common/GraphAccessDeniedException)
. (import-script common/TypeUriParameterCompleter)

function Get-GraphResourceWithMetadata {
    [cmdletbinding(positionalbinding=$false, supportspaging=$true, supportsshouldprocess=$true, defaultparametersetname='byuri')]
    param(
        [parameter(position=0, parametersetname='byuri', valuefrompipeline=$true)]
        [Uri] $Uri = $null,

        [parameter(position=1)]
        [Alias('Property')]
        [String[]] $Select = $null,

        [parameter(position=2)]
        [String] $SimpleMatch = $null,

        [String] $Filter = $null,

        [HashTable] $PropertyFilter = $null,

        [parameter(parametersetname='GraphItem', valuefrompipeline=$true, mandatory=$true)]
        [PSCustomObject] $GraphItem = $null,

        [parameter(parametersetname='GraphUri', valuefrompipelinebypropertyname=$true, mandatory=$true)]
        [Uri] $GraphUri,

        [String] $Query = $null,

        [String] $Search = $null,

        [String[]] $Expand = $null,

        [Alias('Sort')]
        [object[]] $OrderBy = $null,

        [Switch] $Descending,

        [switch] $RawContent,

        [switch] $AbsoluteUri,

        [switch] $IncludeAll,

        [switch] $Recurse,

        [switch] $ChildrenOnly,

        [switch] $DetailedChildren,

        [switch] $ContentOnly,

        [switch] $DataOnly,

        [Switch] $NoRequireMetadata,

        [Switch] $StrictOutput,

        [Switch] $IgnoreUnauthorized,

        [HashTable] $Headers = $null,

        [Guid] $ClientRequestId,

        [string] $ResultVariable = $null,

        [parameter(parametersetname='byuri')]
        [parameter(parametersetname='GraphItem')]
        [parameter(parametersetname='GraphUri', valuefrompipelinebypropertyname=$true, mandatory=$true)]
        [string] $GraphName = $null
    )

    begin {
        Enable-ScriptClassVerbosePreference

        $filters = if ( $SimpleMatch ) { 1 } else { 0 }
        $filters += if ( $Filter ) { 1 } else { 0 }
        $filters += if ( $PropertyFilter ) { 1 } else { 0 }

        if ( $filters -gt 1 ) {
            throw "Only one of SimpleMatch, Filter, or PropertyFilter parameters may be specified -- specify no more than one of these paramters and retry the command."
        }

        $targetFilter = $::.QueryTranslationHelper |=> ToFilterParameter $PropertyFilter $Filter

        $context = $null

        $mustWaitForMissingMetadata = (__Preference__MustWaitForMetadata) -and ! $NoRequireMetadata.IsPresent
        $responseContentOnly = $RawContent.IsPresent -or $ContentOnly.IsPresent

        $results = @()
        $intermediateResults = @()
        $contexts = @()
        $requestInfoCache = @()
    }

    process {
        $assumeRoot = $false

        $specifiedUri = if ( $uri ) {
            $Uri
        } else {
            $GraphUri
        }

        $resolvedUri = if ( $specifiedUri -and $specifiedUri -ne '.' -or $GraphItem ) {
            $GraphArgument = @{}

            if ( $GraphName ) {
                $graphContext = $::.logicalgraphmanager.Get().contexts[$GraphName]
                if ( ! $graphContext ) {
                    throw "The specified graph '$GraphName' does not exist"
                }
                $context = $graphContext.context
                $GraphArgument['GraphScope'] = $GraphName
            }

            if ( $GraphItem -and ( $::.SegmentHelper |=> IsGraphSegmentType $GraphItem ) ) {
                $GraphItem
            } else {
                $targetUri = if ( $GraphItem ) {
                    if ( ! ( $GraphItem | gm id -erroraction ignore ) ) {
                        throw "The GraphItem parameter does not contain the required id property for an item returned by the Graph API or the wrong type was specified to the pipeline -- try specifing the parameter using the parameter name instead of the pipeline, or ensure the type specified to the pipeline is of type [Uri] or a valid object returned by the Graph from a command invocation."
                    }
                    $requestInfo = $::.TypeUriHelper |=> GetTypeAwareRequestInfo $GraphName $null $false $null $GraphItem.id $GraphItem
                    if ( ! $requestInfo.Uri ) {
                        throw "Unable to determine Uri for specified GraphItem parameter -- specify the TypeName or Uri parameter and retry the command"
                    }
                    $requestInfo.Uri
                } else {
                    if ( $specifiedUri.IsAbsoluteUri -and ! $AbsoluteUri.IsPresent ) {
                        throw "The absolute URI '$specifiedUri' was specified, but the AbsoluteUri parameter was not specified. Retry the command with the AbsoluteUri parameter or specify a URI without a hostname instead."
                    }
                    $specifiedUri
                }

                $metadataArgument = @{IgnoreMissingMetadata=(new-object System.Management.Automation.SwitchParameter (! $mustWaitForMissingMetadata))}

                Get-GraphUriInfo $targetUri @metadataArgument @GraphArgument -erroraction stop
            }
        } else {
            $context = $::.GraphContext |=> GetCurrent
            $parser = new-so SegmentParser $context $null $true

            $contextReady = ($::.GraphManager |=> GetMetadataStatus $context) -eq [MetadataStatus]::Ready

            if ( ! $contextReady -and ! $mustWaitForMissingMetadata ) {
                $assumeRoot = $true
                $::.SegmentHelper |=> ToPublicSegment $parser $::.GraphSegment.RootSegment
            } else {
                $::.SegmentHelper |=> ToPublicSegment $parser $context.location
            }
        }

        if ( ! $context ) {
            $parsedPath = $::.GraphUtilities |=> ParseLocationUriPath $resolvedUri.Path
            $context = if ( $parsedPath.ContextName ) {
                $graphContext = $::.logicalgraphmanager.Get().contexts[$parsedPath.ContextName]
                if ( $graphContext ) {
                    $graphContext.context
                }
            }
            if ( ! $context ) {
                throw "'$($resolvedUri.Path)' is not a valid graph location uri"
            }
        }

        # The filter for SimpleMatch can only be determined when the type, and thus the
        # context, is known, so it is request specific and must be computed here.
        if ( $SimpleMatch ) {
            $targetFilter = $::.QueryTranslationHelper |=> GetSimpleMatchFilter $context $resolvedUri.FullTypeName $SimpleMatch
        }

        $requestArguments = @{
            # Handle the case of resolvedUri being incomplete because of missing data -- just
            # try to use the original URI
            Uri = if ( $resolvedUri.Type -ne 'null' ) { $resolvedUri.GraphUri } else { $specifiedUri }
            Query = $Query
            Filter = $targetFilter
            Search = $Search
            Select = $Select
            Expand = $Expand
            OrderBy = $OrderBy
            Descending = $Descending
            RawContent=$RawContent
            Headers=$Headers
            First=$pscmdlet.pagingparameters.first
            Skip=$pscmdlet.pagingparameters.skip
            IncludeTotalCount=$pscmdlet.pagingparameters.includetotalcount
            Connection = $context.connection
            # Due to a defect in ScriptClass where verbose output of ScriptClass work only shows
            # for the current module and not the module we are calling into, we explicitly set
            # verbose for a command from outside this module
            Verbose=([System.Management.Automation.SwitchParameter]::new($VerbosePreference -eq 'Continue'))
        }

        if ( $ClientRequestId ) {
            $requestArguments['ClientRequestId'] = $ClientRequestId
        }

        $graphException = $false

        $ignoreMetadata = ! $mustWaitForMissingMetadata -and ( ($resolvedUri.Class -eq 'Null') -or $assumeRoot )

        $noUri = ! $GraphItem -and ( ! $specifiedUri -or $specifiedUri -eq '.' )

        $emitTarget = $null
        $emitChildren = $null
        $emitRoot = $true

        if ( $StrictOutput.IsPresent ) {
            $emitTarget = $::.SegmentHelper.IsValidLocationClass($resolvedUri.Class) -or $ignoreMetadata
            $emitChildren = ! $resolvedUri.Collection -or $Recurse.IsPresent
        } else {
            $emitTarget = ( ( ! $noUri -or $ignoreMetadata ) -and ! $ChildrenOnly.IsPresent ) -or $resolvedUri.Collection
            $emitRoot = ! $noUri -or $ignoreMetadata
            $emitChildren = ( $noUri -or ! $emitTarget -or $Recurse.IsPresent ) -or $ChildrenOnly.IsPresent
        }

        write-verbose "Uri unspecified: $noUri, Emit Root: $emitRoot, Emit target: $emitTarget, EmitChildren: $emitChildren"

        if ( $resolvedUri.Class -eq '__Root' ) {
            if ( $emitRoot ) {
                $results += $resolvedUri
            }
        } elseif ( $emitTarget ) {
            try {
                $graphResult = Invoke-GraphRequest @requestArguments
                $intermediateResults += $graphResult
                $requestCacheEntry = @{ResolvedRequestUri=$resolvedUri}
                # We need the context with each result, because in theory each result came from a different
                # Graph since we allow arbitrary URI's and objects to be supplied to the pipeline
                $graphResult | foreach {
                    $contexts += $context
                    $requestInfoCache += $requestCacheEntry
                }
            } catch [GraphAccessDeniedException] {
                # In some cases, we want to allow the user to make a mistake that results in an error from Graph
                # but allows the cmdlet to continue to enumerate child segments known from local metadata. For
                # example, the application may not have the scopes to perform a GET on some URI which means Graph
                # has to return a 4xx, but its still valid to enumerate children since the question of what
                # segments may follow a given segment is not affected by scope. Without this accommodation,
                # exploration of the Graph with this cmdlet would be tricky as you'd need to have every possible
                # scope to avoid hitting blocking errors. It's quite possible that you *can't* get all the scopes
                # anyway (you may need admin approval), but you should still be able to see what's possible, especially
                # since that question is one this cmdlet can answer. :)
                $graphException = $true
                $_.exception | write-verbose
                write-warning $_.exception.message
                $lastError = get-grapherror
                if ($lastError -and ($lastError | get-member ResponseStream -erroraction ignore)) {
                    $lastError.ResponseStream | write-warning
                }
            }
        }

        if ( $ignoreMetadata ) {
            write-warning "Metadata processing for Graph is in progress -- responses from Graph will be returned but no metadata will be added. You can retry this cmdlet later or retry it now with the '-NoRequireMetadata' option unspecified or set to `$false to force a wait until processing is complete in order to obtain the complete response."
        }

        if ( ! $DataOnly.ispresent ) {
            if ( ! $ignoreMetadata -and ( $graphException -or $emitChildren ) ) {
                Get-GraphUriInfo $resolvedUri.GraphUri -children -locatablechildren:(!$IncludeAll.IsPresent) | foreach {
                    $results += $_
                }
            }
        }
    }

    end {
        $contextIndex = 0

        # TODO: Results are a flat list even across multiple requests -- this is really complicated because
        # we need to know the context for each result
        foreach ( $intermediateResult in $intermediateResults ) {
            $currentContext = $contexts[$contextIndex] # The context associated with this result
            $contextIndex++
            if ( 'GraphSegmentDisplayType' -in $intermediateResult.pstypenames ) {
                $results += $intermediateResult
                continue
            }

            $restResult = $intermediateResult

            $result = if ( ! $ignoreMetadata -and (! $RawContent.ispresent -and (! $resolvedUri.Collection -or $DetailedChildren.IsPresent) ) ) {
                if ( ! $responseContentOnly ) {
                    $restResult | Get-GraphUriInfo -GraphScope $context.name
                } else {
                    $restResult
                }
            } else {
                if ( ! $responseContentOnly ) {
                    # Getting uri info is expensive, so for a single request, get it only once and cache it
                    $requestSegment = $requestInfoCache[$contextIndex - 1].ResolvedRequestUri
                    if ( ! $requestSegment ) {
                        $requestSegment = Get-GraphUriInfo -GraphScope $context.name $specifiedUri
                        $requestInfoCache[$contextIndex].ResolvedRequestUri = $requestSegment
                    }
                    # The request segment information gives information about the uri used to make the request;
                    # much of that is inherited by elements in the response, so it can be shared across
                    # a large number of elements to improve performance
                    $::.SegmentHelper.ToPublicSegmentFromGraphItem($currentContext, $restResult, $requestSegment)
                } else {
                    $restResult
                }
            }

            $noResults = $false

            # TODO: Investigate scenarios where empty collection results sometimes return
            # a non-empty result containing and empty 'value' field in the content
            if ( $resolvedUri.Collection -and ! $RawContent.IsPresent ) {
                if ( $restResult -and ( $restResult | gm value -erroraction ignore ) -and ! $restResult.value ) {
                    $noResults = $true
                }
            }

            if ( ! $noResults ) {
                $results += $result
            }
        }

        __AutoConfigurePrompt $context

        $targetResultVariable = $::.ItemResultHelper |=> GetResultVariable $ResultVariable
        $targetResultVariable.value = $results

        if ( $results ) {
            $results
        }
    }
}

$::.ParameterCompleter |=> RegisterParameterCompleter Get-GraphResourceWithMetadata Uri (new-so GraphUriParameterCompleter LocationOrMethodUri)
$::.ParameterCompleter |=> RegisterParameterCompleter Get-GraphResourceWithMetadata Select (new-so TypeUriParameterCompleter Property)
$::.ParameterCompleter |=> RegisterParameterCompleter Get-GraphResourceWithMetadata OrderBy (new-so TypeUriParameterCompleter Property)
$::.ParameterCompleter |=> RegisterParameterCompleter Get-GraphResourceWithMetadata Expand (new-so TypeUriParameterCompleter Property $false NavigationProperty)