public/New-AzResourceNameGenerator.ps1

function New-AzResourceNameGenerator {

<#
 
.Author
    Michał Machniak
 
.SYNOPSIS
    Azure Resource Naming Convention Generator
.DESCRIPTION
    This script generates Azure resource names based on a predefined naming convention schema and resource-specific rules.
    It ensures that the generated names comply with Azure's naming restrictions and best practices.
.PARAMETER resourceTypeName
    The type of Azure resource (e.g., "Virtual Machine", "Storage Account").
.PARAMETER regionName
    The Azure region where the resource will be deployed (e.g., "East US", "West Europe").
.PARAMETER uniqueidentifier
    A unique identifier for the resource, such as a project code or application name.
.PARAMETER environment
    The environment in which the resource will be used (e.g., "Dev", "Test", "Prod").
.PARAMETER number
    An optional number to append to the resource name for uniqueness (default is 1).
.PARAMETER separator
    The character used to separate different parts of the resource name (default is "-").
.PARAMETER convertTolower
    A switch to convert the final resource name to lowercase (default is $true).
 
.PARAMETER ResourceNameSchema
    Path to the resource scheama JSON file that defines general naming convention.
.PARAMETER ResourcesData
    Use default settings to load resource schema from web in JSON format.
.EXAMPLE
    New-AzResourceNameGenerator -environment Prod -resourceTypeName @("Storage/storageAccounts", "Web/sites", "Subscription/subscriptions") -regionName "West Europe" -uniqueidentifier MARK@ -number 1 -separator "-"
    New-AzResourceNameGenerator -environment Prod -resourceTypeName @("Storage/storageAccounts", "Web/sites") -regionName "West Europe" -uniqueidentifier MARK -number 1 -separator "-" -convertTolower $true
.NOTES
    Ensure that the .data\resource_schema.json and .data\general_naming_shema.json files are present in the script directory.
    Adjust the paths in the script if necessary to point to the correct location of these files.
 
    New-AzResourceNameGenerator -environment Prod -resourceTypeName @("Storage/storageAccounts", "Web/sites", "Subscription/subscriptions") -regionName "West Europe" -uniqueidentifier MARK@ -number 1 -separator "-"
    New-AzResourceNameGenerator -environment Prod -resourceTypeName @("Storage/storageAccounts", "Web/sites") -regionName "West Europe" -uniqueidentifier MARK -number 1 -separator "-" -convertTolower $true
 
 
#>


[CmdletBinding()]
param(

    [Parameter(Mandatory = $true, HelpMessage = "Specify the environment in which the resource will be used (e.g., Dev, Test, Prod).")]
    [string]$environment,

    [Parameter(HelpMessage = "Select the type of Azure resource.")]
    [string[]]$resourceTypeNames,

    [Parameter(Mandatory = $true, HelpMessage = "Select the Azure region where resources will be deployed.")]
    [string]$regionName,

    [Parameter(Mandatory = $true, HelpMessage = "Provide a unique identifier for the resource, such as a project code or application name.")]
    [string]$uniqueidentifier,

    [Parameter(Mandatory = $true, HelpMessage = "An optional number to append to the resource name for uniqueness.")]
    [int]$number = 1,

    [Parameter(Mandatory = $true, HelpMessage = "The character used to separate different parts of the resource name.")]
    [string]$separator = "-",

    [Parameter(HelpMessage = "A switch to convert the final resource name to lowercase.")]
    [bool]$convertTolower = $false,

    [Parameter(HelpMessage = "Include property in the resource name if applicable.")]
    [string]$property,

    [Parameter(HelpMessage = "Path to the resource scheama JSON file.")]
    [string]$ResourceNameSchema = "https://raw.githubusercontent.com/mimachniak/AzureResources-NameGenerator/refs/heads/main/data/general_naming_shema.json",


    [Parameter(HelpMessage = "Use default settings to load resource schema from web.")]
    [string]$ResourcesData = "https://raw.githubusercontent.com/mspnp/AzureNamingTool/refs/heads/main/src/repository/resourcetypes.json"

    )

    # Function process to sanitize string based on regex
    function Sanitize-String {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$string,
        [Parameter(Mandatory = $true)][string]$regex
    )

    Write-Verbose "Sanitize-String: Input='$string' Regex='$regex'"

    # quick pass if already valid
    if ($string -match $regex) {
        Write-Verbose "String already valid per regex."
        return @{ Sanitized = $string; RemovedChars = "" }
    }

    $sanitized = $string
    $removedChars = ""

    # --- Extract bracket groups and expand ranges into literal characters ---
    $allowedCharsMatches = [regex]::Matches($regex, '\[([^\]]+)\]')
    $expandedAllowed = ""

    foreach ($m in $allowedCharsMatches) {
        $group = $m.Groups[1].Value
        # Use -creplace (case-sensitive) to correctly expand ranges
        $group = $group -creplace 'a-z', 'abcdefghijklmnopqrstuvwxyz'
        $group = $group -creplace 'A-Z', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
        $group = $group -creplace '0-9', '0123456789'
        $expandedAllowed += $group
        Write-Verbose "Expanded character group: '$group'"
    }

    if (-not $expandedAllowed) {
        $expandedAllowed = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-'
    }

    # Preserve first-seen order and remove duplicates
    $seen = New-Object System.Collections.Generic.List[char]
    foreach ($c in $expandedAllowed.ToCharArray()) {
        if (-not $seen.Contains($c)) { $seen.Add($c) }
    }
    $uniqueAllowed = -join $seen

    # --- Determine case rules from the expanded literal set ---
    $hasUpper = $false; $hasLower = $false
    foreach ($c in $uniqueAllowed.ToCharArray()) {
        if ([char]::IsUpper($c)) { $hasUpper = $true }
        if ([char]::IsLower($c)) { $hasLower = $true }
    }

    Write-Verbose "Expanded allowed chars (literal): $uniqueAllowed"
    Write-Verbose "Case detection -> HasUpper=$hasUpper HasLower=$hasLower"

    # --- Build safe character class: escape meta-chars, move hyphen last ---
    $safeAllowed = $uniqueAllowed -replace '([\\\^\]\[])', '\\$1'
    if ($safeAllowed -match '-') {
        $safeAllowed = ($safeAllowed -replace '-', '') + '-'
    }
    $pattern = "[^$safeAllowed]"
    Write-Verbose "Constructed removal pattern: $pattern"

    # --- Remove disallowed chars and record them ---
    $removedChars = -join (($sanitized -split '') | Where-Object { $_ -match $pattern })
    $sanitized = ($sanitized -replace $pattern, '')

    # --- Trim invalid leading/trailing dashes if regex forbids them ---
    if ($regex -match '^\^\[a-zA-Z0-9\]' -and $regex -match '\[a-zA-Z0-9\]\$$') {
        $beforeTrim = $sanitized
        $sanitized = $sanitized.Trim('-')
        if ($beforeTrim -ne $sanitized) {
            Write-Verbose "Trimmed leading/trailing hyphens."
        }
    }

    # --- Enforce max length if present ---
    $max = $null
    if ($regex -match '\{(?:\d+),(\d+)\}') {
        $max = [int]$matches[1]
    } elseif ($regex -match '\{(\d+)\}') {
        $max = [int]$matches[1]
    }
    if ($max -and $sanitized.Length -gt $max) {
        $removedFromTrim = $sanitized.Substring($max)
        $sanitized = $sanitized.Substring(0, $max)
        $removedChars += $removedFromTrim
        Write-Verbose "Trimmed to max length $max"
    }

    # --- Defensive case application using expanded literal set ---
    if ($hasLower -and -not $hasUpper) {
        Write-Verbose "Lowercase-only allowed by expanded set → converting to lowercase (Invariant)."
        $sanitized = $sanitized.ToLowerInvariant()
    } elseif ($hasUpper -and $hasLower) {
        Write-Verbose "Mixed-case allowed by expanded set → preserving case."
    } else {
        Write-Verbose "Case not explicit or digits-only → preserving original case."
    }

    Write-Verbose "Output: Sanitized='$sanitized' Removed='$removedChars'"

    return @{
        Sanitized    = $sanitized
        RemovedChars = $removedChars
    }
    } # end function Sanitize-String

# check there is data in response for resource types
    if ($ResourcesData -eq "https://raw.githubusercontent.com/mspnp/AzureNamingTool/refs/heads/main/src/repository/resourcetypes.json") {
        Write-Host "Using default WEB data source for resource types, defined in this repo: https://github.com/mspnp/AzureNamingTool"
    }

    if ($ResourcesData -match '^(http://|https://|ftp://)') {
        write-Verbose  "Loading WEB data from: $ResourcesData"
        try {
                $responseResources = Invoke-RestMethod -Uri $ResourcesData

            } catch {
                Throw "Failed to fetch data from URL: $ResourcesData. Error: $_"
            }
    }
    elseif (Test-Path $ResourcesData) {
        #return 'Local Path'
        $responseResources = Get-Content -Path $ResourcesData -Raw
    }
    else {
        return 'Unknown'
        Write-Verbose "Sanitize-String: '$string' using regex '$regex' → pattern '$pattern' → '$sanitized'"

    }

    # check there is data in response for schema
    if ($ResourceNameSchema -match '^(http://|https://|ftp://)') {
        Write-Verbose  "General naming convention schema loaded web: $ResourceNameSchema"
        try {
                $responseName = Invoke-RestMethod -Uri $ResourceNameSchema

            } catch {
                Throw "Failed to fetch data from URL: $ResourceNameSchema. Error: $_"
            }
    }
    elseif (Test-Path $ResourceNameSchema) {
        Write-Verbose  "General naming convention schema loaded Local Path: $ResourceNameSchema"
        $responseName = Get-Content -Path $ResourceNameSchema -Raw | ConvertFrom-Json 
    }
    else {
        return 'Unknown'
    }

    # Generating general naming schema pattern

    $generalNamingSchema = $responseName | Sort-Object order
    $generalSchemaPattern = ($generalNamingSchema | Select-Object -ExpandProperty name) -join $separator
    Write-Verbose  "General naming schema defined in file: $generalSchemaPattern"
    
    # check there is data in response
    if (-Not $responseResources) {
        Throw "No data found in the response resource won't be generated"
    } else {

        foreach ($resourceTypeName in $resourceTypeNames) {
        Write-Verbose  "Processing resource type definition: $resourceTypeName"


        $ResourceData = $responseResources | where-object {$_.resource -eq "$resourceTypeName"}
        foreach ($resource in $ResourceData) {
            Write-Verbose  "Generating name for resource type $($resourceTypeName) with ShortName: $($resource.ShortName), regex $($resource.regx), max length $($resource.lengthMax), valid text $($resource.validText)"

            # Put parameters into hashtable dynamically for resources
            $mappingTable = @{
                resourceTypeName = $resourceTypeName
                regionName       = $regionName
                uniqueidentifier = $uniqueidentifier
                environment      = $environment
                abbreviation     = $resource.ShortName
                number           = $number
            }

            # Mpping and generating resource name parts

            $resourceNameParts = foreach ($attribute in $generalNamingSchema) {
                
                $name   = $attribute.name
                $attributeValue = $mappingTable[$name]
                $length    = [int]$attribute.length
                $transformation = [bool]$attribute.transformation

                if ($transformation -eq $true -and $attribute.transformationRegex.pattern -ne $null -and $attribute.transformationRegex.replacement -ne $null) {
                    $attributeValue = [regex]::Replace($attributeValue, $attribute.transformationRegex.pattern, $attribute.transformationRegex.replacement)
                } 

                # Truncate to limit and don't transform just substring
                if (($attributeValue.length -gt $length) -and ($transformation -eq $false)) {
                $attributeValue = $attributeValue.Substring(0, $length)
                }

                $attributeValue
            }

            # to lower case if switch is set
                if ($convertTolower) {
                    $finalResourceName = ($resourceNameParts -join $separator).ToLower()
                    Write-Output  "Generated resource $($resourceTypeName) name base on schema and transformation: $($finalResourceName)"

                }   else {
                    $finalResourceName = $resourceNameParts -join $separator
                    Write-Output  "Generated resource $($resourceTypeName) name base on schema and transformation: $($finalResourceName)"
                }
            # Validate and sanitize resource name based on regex from resource definition

            $original = $finalResourceName
            $regex = $($resource.regx | Out-String)
            if ($resource.regx -eq "" -or $resource.regx -eq $null) {
                Write-Warning "No regex found for resource type: $resourceTypeName. Skipping validation."
                Write-Host "Resource name: $($finalResourceName)"
                continue
            } else {
                write-Verbose  "Validating resource name: $original against regex: $regex"
                if ($original -match $regex) {
                 Write-Host "Valid: $original"
                }
                else {
                $result = Sanitize-String -string $original -regex "$regex"
                Write-Verbose "Orginal string base on parameters: $original"
                Write-Host "Resource name: $($result.Sanitized)"
                Write-Verbose "Removed characters form string: $($result.RemovedChars)"
                }
            }
  

            } # foreach end resource in resourceTypeName
    } # foreach end resourceTypeNames


    } # else end respond is true


} # function end process