Geocoding.psm1

function ConvertFrom-GeoBingMapsOutput {
    <#
    .SYNOPSIS
        Convert output from Bing Maps to uniform output format.
 
    .DESCRIPTION
        Convert output from Bing Maps to uniform output format.
 
    .PARAMETER Resource
        The output from Bing Maps
 
    .EXAMPLE
        Convert the output
 
        PS> ConvertFrom-GeoBingMapsOutput -Resource $output
    #>


    [CmdLetBinding()]
    [OutputType([Object])]

    Param (
        [Parameter(Mandatory = $true, Position = 1)]
        [Object] $Resource
    )

    Write-Debug "[BingMaps] convert output"

    return [PSCustomObject]@{
        "Coordinates" = [PSCustomObject]@{
            "Latitude"  = $Resource.point.coordinates[0]
            "Longitude" = $Resource.point.coordinates[1]
        }
        "Address"     = [PSCustomObject]@{
            "Street Address" = $Resource.address.addressLine
            "Locality"       = $Resource.address.locality
            "Region"         = $Resource.address.adminDistrict
            "Postal Code"    = $Resource.address.postalCode
            "Country"        = $Resource.address.countryRegion
        }
        "Boundingbox" = [PSCustomObject]@{
            "South Latitude" = $Resource.bbox[0]
            "West Longitude" = $Resource.bbox[1]
            "North Latitude" = $Resource.bbox[2]
            "East Longitude" = $Resource.bbox[3]
        }
    }
}


function ConvertFrom-GeoGoogleMapsOutput {
    <#
    .SYNOPSIS
        Convert output from Google Maps to uniform output format.
 
    .DESCRIPTION
        Convert output from Google Maps to uniform output format.
 
    .PARAMETER Resource
        The output from Google Maps
 
    .EXAMPLE
        Convert the output
 
        PS> ConvertFrom-GeoGoogleMapsOutput -Resource $output
    #>


    [CmdLetBinding()]
    [OutputType([Object])]

    Param (
        [Parameter(Mandatory = $true, Position = 1)]
        [Object] $Resource
    )

    Write-Debug "[GoogleMaps] convert output"

    return [PSCustomObject]@{
        "Coordinates" = [PSCustomObject]@{
            "Latitude"  = $Resource.geometry.location.lat
            "Longitude" = $Resource.geometry.location.lng
        }
        "Address"     = [PSCustomObject]@{
            "Street Address" = ($Resource.address_components | Where-Object {$_.types -like "*route*"}).long_name + " " + ($Resource.address_components | Where-Object {$_.types -like "*street_number*"}).long_name
            "Locality"       = ($Resource.address_components | Where-Object {$_.types -like "*locality*"}).long_name
            "Region"         = ($Resource.address_components | Where-Object {$_.types -like "*administrative_area_level_1*"}).long_name
            "Postal Code"    = ($Resource.address_components | Where-Object {$_.types -like "*postal_code*"}).long_name
            "Country"        = ($Resource.address_components | Where-Object {$_.types -like "*country*"}).long_name
        }
        "Boundingbox" = [PSCustomObject]@{
            "South Latitude" = $Resource.geometry.viewport.southwest.lat
            "West Longitude" = $Resource.geometry.viewport.southwest.lng
            "North Latitude" = $Resource.geometry.viewport.northeast.lat
            "East Longitude" = $Resource.geometry.viewport.northeast.lng
        }
    }
}


function ConvertFrom-GeoNominatimOutput {
    <#
    .SYNOPSIS
        Convert output from Open Street Maps to uniform output format.
 
    .DESCRIPTION
        Convert output from Open Street Maps to uniform output format.
 
    .PARAMETER Resource
        The output from Open Street Maps
 
    .EXAMPLE
        Convert the output
 
        PS> ConvertFrom-GeoNominatimOutput -Resource $output
    #>


    [CmdLetBinding()]
    [OutputType([Object])]

    Param (
        [Parameter(Mandatory = $true, Position = 1)]
        [Object] $Resource
    )

    Write-Debug "[OpenStreetMaps] convert output"

    return [PSCustomObject]@{
        "Coordinates" = [PSCustomObject]@{
            "Latitude"  = $Resource.lat
            "Longitude" = $Resource.lon
        }
        "Address"     = [PSCustomObject]@{
            "Street Address" = $Resource.address.road + " " + $Resource.address.house_number
            "Locality"       = $Resource.address.city
            "Region"         = $Resource.address.state
            "Postal Code"    = $Resource.address.postcode
            "Country"        = $Resource.address.country
        }
        "Boundingbox" = [PSCustomObject]@{
            "South Latitude" = $Resource.boundingbox[0]
            "West Longitude" = $Resource.boundingbox[2]
            "North Latitude" = $Resource.boundingbox[1]
            "East Longitude" = $Resource.boundingbox[3]
        }
    }
}


function Find-GeoCodeLocationBingMaps {
    <#
    .SYNOPSIS
        Find a geographical location based on a query or coordinates in Bing Maps.
 
    .DESCRIPTION
        Find a geographical location based on a query or coordinates in Bing Maps.
 
    .PARAMETER Query
        A textual query for the location, this is what you would normally enter in the search bar for the map service. Can't be used together with Lat/Long.
 
    .PARAMETER Latitude
        The latitude as a float. Can't be used together with Query.
 
    .PARAMETER Longitude
        The longitude as a float. Can't be used together with Query.
 
    .PARAMETER Apikey
        Apikey from Bing
 
    .PARAMETER Limit
        Limits the amount of results being returned.
 
    .EXAMPLE
        Find based on query
 
        PS> Find-GeoCodeLocationBingMaps -Query "Microsoft Building 92, NE 36th St, Redmond, WA 98052, United States" -Apikey <YOUR API KEY>
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification='The product is called like this.')]

    [CmdLetBinding()]
    [OutputType([System.Object[]])]

    Param (
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = 'Query')]
        [String] $Query,

        [Alias("Lat")]
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = 'Lon/Lat')]
        [Single] $Latitude,

        [Alias("Lon")]
        [Parameter(Mandatory = $true, Position = 2, ParameterSetName = 'Lon/Lat')]
        [Single] $Longitude,

        [Parameter(Mandatory = $true, Position = 2, ParameterSetName = 'Query')]
        [Parameter(Mandatory = $true, Position = 3, ParameterSetName = 'Lon/Lat')]
        [String] $ApiKey,

        [Parameter(Mandatory = $false, Position = 3, ParameterSetName = 'Query')]
        [Parameter(Mandatory = $false, Position = 4, ParameterSetName = 'Lon/Lat')]
        [Int32] $Limit
    )

    switch($PsCmdlet.ParameterSetName) {
        "Query" {
            Write-Debug "Q"
            $uri = "http://dev.virtualearth.net/REST/v1/Locations/$([System.Web.HttpUtility]::UrlEncode($Query))?o=json&key=$ApiKey"

            if($Limit) {
                $uri += "&maxResults=$Limit"
            }
        }

        "Lon/Lat" {
            $uri = "http://dev.virtualearth.net/REST/v1/Locations/$($Latitude),$($Longitude)?o=json&key=$ApiKey"
        }
    }

    Write-Debug "[BingMaps] Call uri: $uri"
    return Invoke-RestMethod -Uri $uri -Method GET -RetryIntervalSec 1 -MaximumRetryCount 5
}

function Find-GeoCodeLocationGoogleMaps {
    <#
    .SYNOPSIS
        Find a geographical location based on a query or coordinates in Google Maps.
 
    .DESCRIPTION
        Find a geographical location based on a query or coordinates in Google Maps.
 
    .PARAMETER Query
        A textual query for the location, this is what you would normally enter in the search bar for the map service. Can't be used together with Lat/Long.
 
    .PARAMETER Latitude
        The latitude as a float. Can't be used together with Query.
 
    .PARAMETER Longitude
        The longitude as a float. Can't be used together with Query.
 
    .PARAMETER Apikey
        Apikey from Google
 
    .PARAMETER Language
        The language of the returned values can be changed based on the language. Use a country code which is accepted in the header Accept-Language (like "en-US").
 
    .EXAMPLE
        Find based on query
 
        PS> Find-GeoCodeLocationGooleMaps -Query "Microsoft Building 92, NE 36th St, Redmond, WA 98052, United States" -Apikey <YOUR API KEY>
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification='The product is called like this.')]

    [CmdLetBinding()]
    [OutputType([System.Object[]])]

    Param (
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = 'Query')]
        [String] $Query,

        [Alias("Lat")]
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = 'Lon/Lat')]
        [Single] $Latitude,

        [Alias("Lon")]
        [Parameter(Mandatory = $true, Position = 2, ParameterSetName = 'Lon/Lat')]
        [Single] $Longitude,

        [Parameter(Mandatory = $true, Position = 2, ParameterSetName = 'Query')]
        [Parameter(Mandatory = $true, Position = 3, ParameterSetName = 'Lon/Lat')]
        [String] $ApiKey,

        [Parameter(Mandatory = $false, Position = 4, ParameterSetName = 'Query')]
        [Parameter(Mandatory = $false, Position = 5, ParameterSetName = 'Lon/Lat')]
        [String] $Language = "en-US"
    )

    # Create the headers
    $headers = @{
        "accept-language" = $Language
    }

    switch($PsCmdlet.ParameterSetName) {
        "Query" {
            $uri = "https://maps.googleapis.com/maps/api/geocode/json?address=$([System.Web.HttpUtility]::UrlEncode($Query))&key=$ApiKey"
        }

        "Lon/Lat" {
            $uri = "https://maps.googleapis.com/maps/api/geocode/json?latlng=$($Latitude),$($Longitude)&key=$ApiKey"
        }
    }

    Write-Debug "[GoogleMaps] Call uri: $uri"
    return Invoke-RestMethod -Uri $uri -Method GET -RetryIntervalSec 1 -MaximumRetryCount 5 -Headers $headers
}

function Find-GeoCodeLocationNominatim {
    <#
    .SYNOPSIS
        Find a geographical location based on a query or coordinates in Open Street Maps.
 
    .DESCRIPTION
        Find a geographical location based on a query or coordinates in Open Street Maps.
 
    .PARAMETER Query
        A textual query for the location, this is what you would normally enter in the search bar for the map service. Can't be used together with Lat/Long.
 
    .PARAMETER Latitude
        The latitude as a float. Can't be used together with Query.
 
    .PARAMETER Longitude
        The longitude as a float. Can't be used together with Query.
 
    .PARAMETER DetailedAddress
        Split the address info into seperate attributes in the output.
 
    .PARAMETER Limit
        Limits the amount of results being returned.
 
    .PARAMETER Language
        The language of the returned values can be changed based on the language. Use a country code which is accepted in the header Accept-Language (like "en-US").
 
    .EXAMPLE
        Find based on query
 
        PS> Find-GeoCodeLocationNominatim -Query "Microsoft Building 92, NE 36th St, Redmond, WA 98052, United States"
    #>


    [CmdLetBinding()]
    [OutputType([System.Object[]])]

    Param (
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = 'Query')]
        [String] $Query,

        [Alias("Lat")]
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = 'Lon/Lat')]
        [Single] $Latitude,

        [Alias("Lon")]
        [Parameter(Mandatory = $true, Position = 2, ParameterSetName = 'Lon/Lat')]
        [Single] $Longitude,

        [Parameter(Mandatory = $false, Position = 2, ParameterSetName = 'Query')]
        [Parameter(Mandatory = $false, Position = 3, ParameterSetName = 'Lon/Lat')]
        [Switch] $DetailedAddress,

        [Parameter(Mandatory = $false, Position = 3, ParameterSetName = 'Query')]
        [Parameter(Mandatory = $false, Position = 4, ParameterSetName = 'Lon/Lat')]
        [Int32] $Limit,

        [Parameter(Mandatory = $false, Position = 4, ParameterSetName = 'Query')]
        [Parameter(Mandatory = $false, Position = 5, ParameterSetName = 'Lon/Lat')]
        [String] $Language = "en-US"
    )

    # Create the headers
    $headers = @{
        "accept-language" = $Language
    }

    switch($PsCmdlet.ParameterSetName) {
        "Query" {
            $uri = "https://nominatim.openstreetmap.org/search?q=$([System.Web.HttpUtility]::UrlEncode($Query))&format=jsonv2"

            if($Limit) {
                $uri += "&limit=$Limit"
            }
        }

        "Lon/Lat" {
            $uri = "https://nominatim.openstreetmap.org/reverse?lat=$Latitude&lon=$Longitude&format=jsonv2"
        }
    }

    if($DetailedAddress) {
        $uri += "&addressdetails=1"
    }

    Write-Debug "[OpenStreetMaps] Call uri: $uri"
    return Invoke-RestMethod -Uri $uri -Method GET -RetryIntervalSec 1 -MaximumRetryCount 5 -Headers $headers
}

function Find-GeoCodeLocation {
    <#
    .SYNOPSIS
        Find a geographical location based on a query or coordinates.
 
    .DESCRIPTION
        Find a geographical location based on a query or coordinates. It supports multiple providers being: Open Street Maps, Bing Maps and Google Maps.
 
    .PARAMETER Query
        A textual query for the location, this is what you would normally enter in the search bar for the map service. Can't be used together with Lat/Long.
 
    .PARAMETER Latitude
        The latitude as a float. Can't be used together with Query.
 
    .PARAMETER Longitude
        The longitude as a float. Can't be used together with Query.
 
    .PARAMETER Provider
        The service to use to find the location. It supports Open Street Maps (OSM), Bing Maps (Bing) and Google Maps (Google).
        To use Bing and Google an API key is required which needs to be requested via their service. Open Street Maps can be used without an API key.
        Default it will use Open Street Maps
 
    .PARAMETER Apikey
        Required when using Google or Bing. Needs to be entered as a string.
 
    .PARAMETER Limit
        Limits the amount of results being returned.
 
    .PARAMETER Language
        For Open Street Maps and Google the language of the returned values can be changed based on the language. Use a country code which is accepted in the header Accept-Language (like "en-US").
 
    .EXAMPLE
        Use OpenStreetMaps to query and return a single result
 
        PS> Find-GeoCodeLocation -Query "Microsoft Building 92, NE 36th St, Redmond, WA 98052, United States" -Provider OSM -Limit 1 | fl *
 
        Coordinates : @{Latitude=47.64249155; Longitude=-122.13692695171639}
        Address : @{Street Address=Northeast 36th Street 15010; Locality=; Region=Washington; Postal Code=98052; Country=United States}
        Boundingbox : @{South Latitude=47.6413399; West Longitude=-122.1378316; North Latitude=47.6433901; East Longitude=-122.1365074}
 
    .EXAMPLE
        Use Bing Maps to query and return a single result
 
        PS> Find-GeoCodeLocation -Query "Microsoft Building 92, NE 36th St, Redmond, WA 98052, United States" -Provider Bing -Apikey <YOUR API KEY> -Limit 1 | fl *
 
        Coordinates : @{Latitude=47,642428; Longitude=-122,05937604}
        Address : @{Street Address=NE 36th St; Locality=Redmond; Region=WA; Postal Code=98074; Country=United States}
        Boundingbox : @{South Latitude=47,6385652824306; West Longitude=-122,06701963172; North Latitude=47,646290717572; East Longitude=-122,051732453158}
 
    .EXAMPLE
        Use Google Maps to query and return a single result
 
        PS> Find-GeoCodeLocation -Query "Microsoft Building 92, NE 36th St, Redmond, WA 98052, United States" -Provider Google -Apikey <YOUR API KEY> -Limit 1 | fl *
 
        Coordinates : @{Latitude=47,6423109; Longitude=-122,1368406}
        Address : @{Street Address=Northeast 36th Street 15010; Locality=Redmond; Region=Washington; Postal Code=System.Object[]; Country=United States}
        Boundingbox : @{South Latitude=47,6410083697085; West Longitude=-122,138480530292; North Latitude=47,6437063302915; East Longitude=-122,135782569708}
 
    .EXAMPLE
        Use OpenStreetMaps to lookup coordinates and return a single result
 
        PS> Find-GeoCodeLocation -Latitude 38.75408328 -Longitude -78.13476563 -Provider OSM -Limit 1 | fl *
 
        Coordinates : @{Latitude=38.75186724786314; Longitude=-78.13181680294852}
        Address : @{Street Address=Fodderstack Road ; Locality=; Region=Virginia; Postal Code=22747; Country=United States}
        Boundingbox : @{South Latitude=38.7196074; West Longitude=-78.1576132; North Latitude=38.7593864; East Longitude=-78.1236110}
 
    .EXAMPLE
        Use Bing Maps to lookup coordinates and return a single result
 
        PS> Find-GeoCodeLocation -Latitude 38.75408328 -Longitude -78.13476563 -Provider Bing -Apikey <YOUR API KEY> -Limit 1 | fl *
 
        Coordinates : @{Latitude=38,75408173; Longitude=-78,13477325}
        Address : @{Street Address=; Locality=Hampton; Region=VA; Postal Code=22747; Country=United States}
        Boundingbox : @{South Latitude=38,7502190085035; West Longitude=-78,1413771887125; North Latitude=38,7579444436449; East Longitude=-78,1281693200765 }
 
    .EXAMPLE
        Use Google Maps to lookup coordinates and return a single result
 
        PS> Find-GeoCodeLocation -Latitude 38.75408328 -Longitude -78.13476563 -Provider Google -Apikey <YOUR API KEY> -Limit 1 | fl *
 
        Coordinates : @{Latitude=38,75408; Longitude=-78,13477}
        Address : @{Street Address= ; Locality=Flint Hill; Region=Virginia; Postal Code=; Country=United States}
        Boundingbox : @{South Latitude=38,7527135197085; West Longitude=-78,1361614802915; North Latitude=38,7554114802915; East Longitude=-78,1334635197085 }
 
    .NOTES
        Open Street Maps: https://nominatim.org/release-docs/latest/api/Overview/
        Bing Maps: https://learn.microsoft.com/en-us/bingmaps/rest-services/locations/
        Google Maps: https://developers.google.com/maps/documentation/geocoding
    #>


    [CmdLetBinding()]
    [OutputType([System.Object[]])]

    Param (
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = 'Query')]
        [String] $Query,

        [Alias("Lat")]
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = 'Lon/Lat')]
        [Single] $Latitude,

        [Alias("Lon")]
        [Parameter(Mandatory = $true, Position = 2, ParameterSetName = 'Lon/Lat')]
        [Single] $Longitude,

        [ValidateSet('OpenStreetMaps', 'OSM', 'BingMaps', 'Bing', 'GoogleMaps', 'Google')]
        [Parameter(Mandatory = $false, Position = 2, ParameterSetName = 'Query')]
        [Parameter(Mandatory = $false, Position = 3, ParameterSetName = 'Lon/Lat')]
        [String] $Provider = "OpenStreetMaps",

        [Parameter(Mandatory = $false, Position = 3, ParameterSetName = 'Query')]
        [Parameter(Mandatory = $false, Position = 4, ParameterSetName = 'Lon/Lat')]
        [Int32] $Limit,

        [Parameter(Mandatory = $false, Position = 4, ParameterSetName = 'Query')]
        [Parameter(Mandatory = $false, Position = 5, ParameterSetName = 'Lon/Lat')]
        [String] $Language = "en-US"
    )

    DynamicParam {
        if($Provider -in 'BingMaps', 'Bing', 'GoogleMaps', 'Google') {
            $attribute = New-Object System.Management.Automation.ParameterAttribute
            $attribute.Mandatory = $true

            $collection = New-Object System.Collections.ObjectModel.Collection[System.Attribute]
            $collection.Add($attribute)

            $param = New-Object System.Management.Automation.RuntimeDefinedParameter('Apikey', [string], $collection)
            $dictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
            $dictionary.Add('Apikey', $param)

            return $dictionary
        }
    }

    Process {
        # do actions for the right provider
        switch ($Provider) {
            { $_ -in "OpenStreetMaps", "OSM" } {
                Write-Debug "[OpenStreetMaps] start processing"
                Send-THEvent -ModuleName "Geocoding" -EventName "Find-GeoCodeLocation" -PropertiesHash @{Provider = "OSM" }

                # Create the parameters
                $splat = $PSBoundParameters
                $null = $splat.Remove("Provider")
                $splat.add("DetailedAddress",$true)

                # Query the provider for the results
                $res = Find-GeoCodeLocationNominatim @splat

                if($Limit) {
                    $res = $res | Select-Object -First $Limit
                }

                # Format the results in a uniform format
                $res = @($res | ForEach-Object {ConvertFrom-GeoNominatimOutput -Resource $_})

                # Return result
                return $res
            }

            { $_ -in "BingMaps", "Bing" } {
                Write-Debug "[BingMaps] start processing"
                Send-THEvent -ModuleName "Geocoding" -EventName "Find-GeoCodeLocation" -PropertiesHash @{Provider = "Bing" }

                # Create the parameters
                $splat = $PSBoundParameters
                $null = $splat.Remove("Provider")
                $null = $splat.Remove("Language")

                # Query the provider for the results
                $res = (Find-GeoCodeLocationBingMaps @splat).resourceSets.resources

                if($Limit) {
                    $res = $res | Select-Object -First $Limit
                }

                # Format the results in a uniform format
                $res = @($res | ForEach-Object {ConvertFrom-GeoBingMapsOutput -Resource $_})

                # Return result
                return $res
            }

            { $_ -in "GoogleMaps", "Google" } {
                Write-Debug "[GoogleMaps] start processing"
                Send-THEvent -ModuleName "Geocoding" -EventName "Find-GeoCodeLocation" -PropertiesHash @{Provider = "Google" }

                # Create the parameters
                $splat = $PSBoundParameters
                $null = $splat.Remove("Provider")
                $null = $splat.Remove("Limit")

                # Query the provider for the results
                $res = (Find-GeoCodeLocationGoogleMaps @splat).results

                if($Limit) {
                    $res = $res | Select-Object -First $Limit
                }

                # Format the results in a uniform format
                $res = @($res | ForEach-Object {ConvertFrom-GeoGoogleMapsOutput -Resource $_})

                # Return result
                return $res
            }
        }
    }
}

# Create env variables
$Env:GEOCODING_TELEMETRY_OPTIN = (-not $Evn:POWERSHELL_TELEMETRY_OPTOUT) # use the invert of default powershell telemetry setting

# Set up the telemetry
Initialize-THTelemetry -ModuleName "Geocoding"
Set-THTelemetryConfiguration -ModuleName "Geocoding" -OptInVariableName "GEOCODING_TELEMETRY_OPTIN" -StripPersonallyIdentifiableInformation $true -Confirm:$false
Add-THAppInsightsConnectionString -ModuleName "Geocoding" -ConnectionString "InstrumentationKey=df9757a1-873b-41c6-b4a2-2b93d15c9fb1;IngestionEndpoint=https://westeurope-5.in.applicationinsights.azure.com/;LiveEndpoint=https://westeurope.livediagnostics.monitor.azure.com/"

# Create a message about the telemetry
Write-Information ("Telemetry for Geocoding module is $(if([string] $Env:GEOCODING_TELEMETRY_OPTIN -in ("no","false","0")){"NOT "})enabled. Change the behavior by setting the value of "+ '$Env:GEOCODING_TELEMETRY_OPTIN') -InformationAction Continue

# Send a metric for the installation of the module
Send-THEvent -ModuleName "Geocoding" -EventName "Import Module Geocoding"