Private/Get-SFResourceStructure.ps1

<#
.SYNOPSIS
Helper script for introspecting SuccessFactors resource schemas and sample payloads.

.DESCRIPTION
This script is a helper utility intended for local analysis and troubleshooting.
It connects to SuccessFactors, retrieves a small sample from each configured resource type,
and writes resource-level outputs with top-level fields, flattened field paths, and sample JSON.

This script is not part of the normal connector runtime flow and is not required when running
Invoke-Connector in production.

.PARAMETER Config
Connector configuration object used by Connect-SF.

.PARAMETER OutputDir
Output folder where schema and sample files are written.

.PARAMETER SampleSize
Number of rows to fetch per resource when generating sample data.

.PARAMETER SchemaDepth
Maximum recursion depth used while flattening field paths.

.OUTPUTS
Writes files to disk and prints a summary table to the console.

.EXAMPLE
$cfg = New-SFConnectorConfiguration -BaseUrl 'https://api55preview.sapsf.eu' -ClientId '...' -CompanyId '...' -UserId '...' -PrivateKeyPath '~/sf-private.pem' -PrivateKeyBodyOnly
./Get-SFResourceStructure.ps1 -Config $cfg

.NOTES
Helper utility script only. Keep in private tooling workflows.
#>

param(
    [Parameter(Mandatory = $true)]
    $Config,

    [string]$OutputDir = "$HOME/Documents/resource-introspection",

    [int]$SampleSize = 3,

    [int]$SchemaDepth = 5
)

# ---------- Helpers ----------
<#
.SYNOPSIS
Helper function that determines whether a value should be treated as scalar.

.DESCRIPTION
Used by schema introspection to stop recursion for null, primitive, and datetime values.

.NOTES
Internal helper function for Get-SFResourceStructure.ps1.
#>

function Test-IsScalar {
    param([object]$Value)

    if ($null -eq $Value) { return $true }
    if ($Value -is [string]) { return $true }
    if ($Value -is [ValueType]) { return $true }
    if ($Value -is [datetime]) { return $true }
    return $false
}

<#
.SYNOPSIS
Helper function that recursively flattens object properties into dot-paths.

.DESCRIPTION
Converts nested objects and arrays into readable path strings for schema inspection,
for example parentNav.externalCode or addressNavDEFLT[].city.

.NOTES
Internal helper function for Get-SFResourceStructure.ps1.
#>

function Get-PropertyPaths {
    param(
        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [object]$Object,
        [string]$Prefix = "",
        [int]$Depth = 0,
        [int]$MaxDepth = 5
    )

    $paths = [System.Collections.Generic.List[string]]::new()

    if ($Depth -ge $MaxDepth) {
        if ($Prefix) { $paths.Add($Prefix) }
        return $paths
    }

    if (Test-IsScalar -Value $Object) {
        if ($Prefix) { $paths.Add($Prefix) }
        return $paths
    }

    if ($Object -is [System.Collections.IDictionary]) {
        foreach ($k in $Object.Keys) {
            $name = [string]$k
            $nextPrefix = if ($Prefix) { "$Prefix.$name" } else { $name }
            $child = $Object[$k]
            $childPaths = Get-PropertyPaths -Object $child -Prefix $nextPrefix -Depth ($Depth + 1) -MaxDepth $MaxDepth
            foreach ($p in $childPaths) { $paths.Add($p) }
        }
        return $paths
    }

    if (($Object -is [System.Collections.IEnumerable]) -and -not ($Object -is [string])) {
        $arr = @($Object)
        if ($arr.Count -eq 0) {
            if ($Prefix) { $paths.Add("$Prefix[]") }
            return $paths
        }

        $nextPrefix = if ($Prefix) { "$Prefix[]" } else { "[]" }
        $childPaths = Get-PropertyPaths -Object $arr[0] -Prefix $nextPrefix -Depth ($Depth + 1) -MaxDepth $MaxDepth
        foreach ($p in $childPaths) { $paths.Add($p) }
        return $paths
    }

    $props = $Object.PSObject.Properties | Where-Object { $_.MemberType -in @("NoteProperty", "Property") }
    if (-not $props) {
        if ($Prefix) { $paths.Add($Prefix) }
        return $paths
    }

    foreach ($prop in $props) {
        $nextPrefix = if ($Prefix) { "$Prefix.$($prop.Name)" } else { $prop.Name }
        $childPaths = Get-PropertyPaths -Object $prop.Value -Prefix $nextPrefix -Depth ($Depth + 1) -MaxDepth $MaxDepth
        foreach ($p in $childPaths) { $paths.Add($p) }
    }

    return $paths
}

<#
.SYNOPSIS
Helper function to append $top to an OData query string.

.DESCRIPTION
Builds a query string that limits sampled rows while preserving existing query options,
for example expand statements.

.NOTES
Internal helper function for Get-SFResourceStructure.ps1.
#>

function Merge-QueryTop {
    param(
        [string]$QueryString,
        [int]$Top
    )

    if ([string]::IsNullOrWhiteSpace($QueryString)) {
        return "?`$top=$Top"
    }

    if ($QueryString.Contains("?")) {
        return "$QueryString&`$top=$Top"
    }

    return "?`$top=$Top"
}

# ---------- Resource definitions ----------
$resourceTypes = @(
    @{ Type = "User";          IdField = "userId" },
    @{ Type = "EmpEmployment"; IdField = "employmentId" },
    @{ Type = "EmpJob";        IdField = "jobinfoId" },
    @{ Type = "FOCostCenter";  IdField = "externalCode" },
    @{ Type = "Position";      IdField = "externalCode" },
    @{ Type = "FODepartment";  IdField = "externalCode"; QueryString = "?`$expand=parentNav,headOfUnitNav" },
    @{ Type = "FOCompany";     IdField = "externalCode" },
    @{ Type = "FOLocation";    IdField = "externalCode"; QueryString = "?`$expand=addressNavDEFLT" },
    @{ Type = "FOJobFunction"; IdField = "externalCode"; QueryString = "?`$expand=parentFunctionCodeNav" },
    @{ Type = "PerPerson";     IdField = "personIdExternal" },
    @{ Type = "PerPersonal";   IdField = "personIdExternal" }
)

# ---------- Run ----------
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null

Connect-SF -ConnectorConfiguration $Config

$summary = foreach ($rt in $resourceTypes) {
    $type = $rt.Type
    $baseQuery = if ($rt.ContainsKey("QueryString")) { $rt.QueryString } else { "" }
    $query = Merge-QueryTop -QueryString $baseQuery -Top $SampleSize

    $sw = [System.Diagnostics.Stopwatch]::StartNew()

    try {
        $rows = @(Invoke-SFRestMethod -BaseUrl $Config.configuration.baseurl -Resource $type -QueryString $query)
        $sw.Stop()

        $resourceDir = Join-Path $OutputDir $type
        New-Item -ItemType Directory -Path $resourceDir -Force | Out-Null

        $sample = @($rows | Select-Object -First $SampleSize)

        $topFields = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $flatFields = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

        foreach ($item in $sample) {
            if ($null -eq $item) {
                continue
            }

            $props = $item.PSObject.Properties | Where-Object { $_.MemberType -in @("NoteProperty", "Property") }
            foreach ($p in $props) { [void]$topFields.Add($p.Name) }

            $paths = Get-PropertyPaths -Object $item -MaxDepth $SchemaDepth
            foreach ($path in $paths) { [void]$flatFields.Add($path) }
        }

        $topFieldsSorted = @($topFields) | Sort-Object
        $flatFieldsSorted = @($flatFields) | Sort-Object

        $topFieldsSorted | Set-Content -Path (Join-Path $resourceDir "fields-top-level.txt")
        $flatFieldsSorted | Set-Content -Path (Join-Path $resourceDir "fields-flattened.txt")
        ($sample | ConvertTo-Json -Depth 20) | Set-Content -Path (Join-Path $resourceDir "sample.json")

        [pscustomobject]@{
            Resource   = $type
            Status     = "OK"
            Rows       = $rows.Count
            SampleRows = $sample.Count
            TopFields  = $topFieldsSorted.Count
            FlatFields = $flatFieldsSorted.Count
            DurationMs = $sw.ElapsedMilliseconds
            Error      = $null
        }
    }
    catch {
        $sw.Stop()
        [pscustomobject]@{
            Resource   = $type
            Status     = "FAIL"
            Rows       = 0
            SampleRows = 0
            TopFields  = 0
            FlatFields = 0
            DurationMs = $sw.ElapsedMilliseconds
            Error      = $_.Exception.Message
        }
    }
}

$summary | Sort-Object Status, Resource | Format-Table -AutoSize
$summary | Export-Csv -NoTypeInformation -Path (Join-Path $OutputDir "summary.csv")
$summary | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $OutputDir "summary.json")

Write-Host ""
Write-Host "Done. Output folder: $OutputDir"