Viewing entries tagged
getting started

PowerShell: Getting Started - Utilizing the Web: Part 4 (Controlling Internet Explorer via IE COM Object)

PowerShell: Getting Started - Utilizing the Web: Part 4 (Controlling Internet Explorer via IE COM Object)

PowerShell: Getting Started - Utilizing the Web: Part 4 (Controlling Internet Explorer via IE COM Object)

Why Control Internet Explorer with PowerShell?

I've covered using Invoke-WebRequest, as well as Invoke-RestMethod for basic and advanced tasks... why would we need anything else? Sometimes you'll try using Invoke-WebRequest, and you swear the data should be there, yet for some reason you just can't seem to parse it out. 

The reasons for this can vary. Typically, it is because the page renders something with a script, and you can only gather it with a browser being opened/controlled. If you're having a hard time parsing the data from Invoke-WebRequest, controlling IE can be a time saving solution. This can come in handy if you need to script up something quick, and then work on a long term solution after you know what's possible.

COM Object Instantiation

The first thing we will need to do is instantiate an instance of the Internet Explorer COM object. To do this, we'll use New-Object.

$ieObject = New-Object -ComObject 'InternetExplorer.Application'

$ieObject will store the object and contains the properties and methods we can work with. Let's take a peek:

$ieObject | Get-Member
gm.PNG

We will be using a few of these properties and methods as we work with controlling IE.

By default, the instance of Internet Explorer is not visible. For automation, that's great!
For demoing and testing, not so much!

Let's show the current instance of IE:

$ieObject.Visible = $true

There's our window.

After instantiating our object, we can use it to navigate to a website.

To do this, we will utilize the Navigate method, and pass along a string value to navigate to.

$ieObject.Navigate('http://www.tukui.org/forums/bb-login.php')

Now the browser should be at the website.

Form Elements/Information Gathering

Now that we have navigated to the site, we will need to login. To login, we'll first need do some some information gathering.

First, we will store the current page's document in a new variable named $currentDocument

Then, we will list out all of the tags named input, along with their associated types and names

$currentDocument = $ieObject.Document
$currentDocument.IHTMLDocument3_getElementsByTagName("input") | Select-Object Type,Name

This gives us enough information to proceed to the next section on filling out the form, however let's take a look at a couple more things.

What if we wanted to gather all the links?

$currentDocument.links | Select-Object outerText,href

If we wanted to get just the download URL, we could use something like:

$downloadLink = $currentDocument.links | Where-Object {$_.outerText -eq 'Download'} | Select-Object -ExpandProperty href
$downloadLink

Hmm... it appears that there are two identical links to the download page provided. To make sure we only grab one, we will need to use something like:

$downloadLink = $currentDocument.links | Where-Object {$_.outerText -eq 'Download'} | Select-Object -ExpandProperty href -First 1
$downloadLink

There we go!

Filling Out Forms and Clicking Buttons

Now that we have parsed out all of the input types, and have their names, we can proceed with filling out the form information.

Here they are for review:

Username

First let's set the username field. 

The username field is named user_login, so let's store that field in a variable named $userNameBox.

$userNameBox = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.name -eq 'user_login'}

To set the username, we can change the value property to our username. I have a credential object which contains my username, and I will use that to set it. You can pass along any string value, but I highly recommend using a credential object, or other secure method (more for the password, but it at also omits even showing the raw string for your username in the code).

$userNameBox.value = $myCredentials.UserName

You can see that this is set on the website now!

Password

Now to set the password. To do this we'll use the same logic as we did for the username, but specify the name password.

$passwordBox = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.name -eq 'password'}

The password object will take a string for the password (just as the username object did). I highly recommend using a credential object, and then using the GetNetworkCredential method, and then the Password property.

$passwordBox.value = $myCredentials.GetNetworkCredential().Password

Looks good!

Clicking Submit ("Log in »")

We've filled out our username and password, what now? Well... that button ain't gonna click itself! Let's click it via PowerShell.

There is no name set on the object for the button, so we'll use its type for uniqueness, and set the variable to store it that way ($submitButton).

$submitButton = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.type -eq 'submit'}

Now to click it! There is a method on this object named Click.

$submitButton | Get-Member -MemberType method

Ok but for real now, let's 'click' it!

$submitButton.click()

And if all goes well, you'll see the next page and be logged in.

Now that we're logged in, we could:

  • Set the $currentDocument variable to $ieObject.Document as the value is now different since a new page has been loaded.
  • Parse out information we need by exploring the webpage.
  • Automate looking for a specific post

It really all depends on what you want to do. 

One thing I have done is wrote up a script for a project where I had to rename user IDs. I had a list of old IDs and new IDs, and would iterate through all of them (sometimes 300+), until they were all renamed. All with logging and results output for later review and verification. Once you have the basics down you can really expand into the realm of full automation, and reap the benefits of what PowerShell can do.

!!**Note**!!

After each time you use the Navigate method, or click a button, you'll want to wait for the object's status to not be busy. 

Here is one way to accomplish that.

Store this function in memory:

function Invoke-IEWait {
    [cmdletbinding()]
    Param(
        [Parameter(
            Mandatory,
            ValueFromPipeLine
        )]
        $ieObject
    )

    While ($ieObject.Busy) {

        Start-Sleep -Milliseconds 10

    }

}

You can store it in the same script, or as part of a module. You can invoke this function whenever you need to now, and keep your code looking clean.

It's also good to always refresh your $currentDocument variable after a button click / page load. 

Here's some code for an example of when/how to use it:

#Set the URL we want to navigate to
$webURL = 'http://www.tukui.org/forums/bb-login.php'

#Create / store object invoked in a variable
$ieObject = New-Object -ComObject 'InternetExplorer.Application'

#By default it will not be visible, and this is likely how you'd want it set in your scripts
#But for demo purposes, lets set it to visible
$ieObject.Visible = $true

#Take a look at the object
$ieObject | Get-Member

#Navigate to the URL we set earlier
$ieObject.Navigate($webURL)

#Wait for the page to load
$ieObject | Invoke-IEWait

#Store current document in variable
$currentDocument = $ieObject.Document

Alright, time to clean things up.

Cleanup

Let's cleanup the mess we made.

First we will log out of the website:

$logoutURL = $currentDocument.links | Where-Object {$_.outerText -eq 'log out'} | Select-Object -ExpandProperty href -First 1
$logoutURL

Now that we've verified the URL (and only have one thanks to Select-Object along with -First 1), we can logout by navigating to it.

$ieObject.Navigate($logoutURL)

Now to verify...

Now to clean up the COM object we instantiated.

First we'll quit IE:

$ieObject.Quit()

Our IE window should have went poof.

If you really want to perform the best cleanup, you can follow that up with this (to release the COM object):

[void][Runtime.Interopservices.Marshal]::ReleaseComObject($ieObject)

Example Code

Here's some example code. I took what we accomplished above, and broke parts of it out into functions.

To use the code you'll need an account on the Tukui forums.

Here's what it does:

  • Prompts for credentials
  • Navigates to the site
    • Enters credentials
    • Clicks Submit
    • Parses links / text from posts
    • Outputs results to text files
  • Logs out of site
  • Quits IE
  • Releases IE COM object
function Invoke-IEWait { #Begin function Invoke-IEWait
    [cmdletbinding()]
    Param(
        [Parameter(
            Mandatory,
            ValueFromPipeLine
        )]
        $ieObject
    )

    While ($ieObject.Busy) {

        Start-Sleep -Milliseconds 10

    }

} #End function Invoke-IEWait

function Invoke-SiteLogon { #Begin function Invoke-SiteLogon
    [cmdletbinding()]
    param()

    #Set the URL we want to navigate to
    $webURL = 'http://www.tukui.org/forums/bb-login.php'

    #Create / store object invoked in a variable
    $ieObject = New-Object -ComObject 'InternetExplorer.Application'

    #By default it will not be visible, and this is likely how you'd want it set in your scripts
    #But for demo purposes, let's set it to visible
    $ieObject.Visible = $true

    #Navigate to the URL we set earlier
    $ieObject.Navigate($webURL)

    #Wait for the page to load
    $ieObject | Invoke-IEWait

    #Store current document in a variable
    $currentDocument = $ieObject.Document

    #Username field
    $userNameBox = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.name -eq 'user_login'}

    #Fill out username value
    $userNameBox.value = $myCredentials.UserName

    #Password field
    $passwordBox = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.name -eq 'password'}

    #Fill out password value
    $passwordBox.value = $myCredentials.GetNetworkCredential().Password

    #Submit button
    $submitButton = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.type -eq 'submit'}

    #Invoke click method on submit button
    $submitButton.click()

    #Wait for the page to load
    $ieObject | Invoke-IEWait

    #Return the object so we can work with it further in the script
    Return $ieObject

} #End function Invoke-SiteLogon

function Invoke-IECleanUp { #Begin function Invoke-IECleanUp
    [cmdletbinding()]
    param(
        [Parameter(
            Mandatory,
            ValueFromPipeLine
        )]
        $ieObject        
    )

    #Store logout URL
    $logoutURL = $currentDocument.links | Where-Object {$_.outerText -eq 'log out'} | Select-Object -ExpandProperty href -First 1

    #Use logout URL to logout via the Navigate method
    $ieObject.Navigate($logoutURL)

    #Wait for logout
    $ieObject | Invoke-IEWait

    #Clean up IE Object
    $ieObject.Quit()

    #Release COM Object
    [void][Runtime.Interopservices.Marshal]::ReleaseComObject($ieObject)

} #End function Invoke-IECleanUp

#Get credentials
$myCredentials = Get-Credential

#Login to site
$ieObject = Invoke-SiteLogon

#Wait in case it is still busy
$ieObject | Invoke-IEWait

#Set the current document variable 
$currentDocument = $ieObject.Document

#Get all elements that may have text values in a table, and store in a text file
$currentDocument.IHTMLDocument3_getElementsByTagName('td') | Select-Object OuterText | Out-File '.\siteStuff.txt'

#Get all links and select name/href, and store in a text file
$currentDocument.links | Select-Object outerText,href | Out-File '.\links.txt'

#Log out / clean up IE Object now that we're done
$ieObject | Invoke-IECleanUp

It will create the text files in the current working directory.

Homework

I've provided some links for hints.

I hope you've enjoyed this series.
As always, leave a comment if you have any feedback or questions!

[top]

PowerShell: Getting Started - Utilizing the Web: Part 3 (More Invoke-RestMethod)

PowerShell: Getting Started - Utilizing the Web: Part 3 (More Invoke-RestMethod)

Getting Started - Utilizing the Web: Part 3 (More Invoke-RestMethod)

Welcome to my Getting Started with Windows PowerShell series!

Continuing With Invoke-RestMethod

In part 2, we went over Invoke-RestMethod's basics, including:

In this post, I will be going over some more ways to use Invoke-RestMethod to authenticate to different APIs. I will also go over how to send information to the API, and work with the results we get back.

API Key In Header

Some APIs will require you to authenticate with a key in the header information. I happen to own a LIFX light, and their API uses that method of authentication.

If you own a LIFX light, and want to follow along, you can get a personal access token here: https://cloud.lifx.com/settings.

Information recap:

Now let's get to work! I have my key, which let's just say is: 'YouNeedYourOwnKey123321$'

Let's store that in a variable.

$apiKey          = 'YouNeedYourOwnKey123321$'

The next step here is to store that key in the header information (as a hashtable), with the key name of "Authorization".

$headers         =  @{Authorization=("Bearer {0}" -f $apiKey)}

Let's take a peek at $headers to ensure it contains what we need.

Looks good! Now to check it out. The URL in the documentation to get the lights available is 'https://api.lifx.com/v1/lights/all'. I like to store information like this in variables. 

$allURL          = 'https://api.lifx.com/v1/lights/all'

Now to put it all to use! We'll be utilizing Invoke-RestMethod with the following parameters/arguments:

  • Headers
    • Since we already have our headers stored in a nifty hashtable, we'll simply specify $headers
  • Uri
    • We'll pass along $allURL, and attempt to get a list of available lights

Here's all of the code so far. 

We'll be storing the results of the Invoke-RestMethod command in $ninjaLights.

$apiKey          = 'YouNeedYourOwnKey123321$'
$headers         =  @{Authorization=("Bearer {0}" -f $apiKey)}
$allURL          = 'https://api.lifx.com/v1/lights/all'

$ninjaLights     = Invoke-RestMethod -Headers $headers -Uri $allURL -ContentType 'application/json'

Success! 

Sending Data to a REST API via Body (LIFX API)

Now that we've authenticated to LIFX, we can work on sending information over, and control the light. Here are the steps I generally follow when using an API for the first time:

  • Review documentation
  • Review documentation (This can't be emphasized enough)
  • Test out a bunch of different ways to utilize the API
    • Get errors, handle errors, see what's expected, clean up the code, and build a function

Here is a snippet from their documentation:

What that tells us is how the text is to be formatted (JSON)and that we'll be using the PUT method.

Here are the parameters they accept:

Let's get started by ensuring we have working code, and then build a function around it.

The URL is 'https://api.lifx.com/v1/lights/all/state', which we'll store in $lightStateURL. We'll also setup some variables for the accepted parameters, as follows:

  • $state
    • on or off, we'll choose on
  • $color
    • We'll set this to red
  • $brightness
    • We'll set this to .5, or 50%
  • $duration
    • This will be set to .0, which is indefinitely

After setting up these variables, we'll use a here-string to build the payload, and finally use Invoke-RestMethod with the following parameters/arguments:

  • Uri
    • The $lightStateURL variable
  • Method
    • We'll be using PUT, per their documentation
  • Headers
  • Body
    • We will set this to $payloadBuilder (which is the here-string that contains the parameters we're passing to the LIFX API)

Here is the full code so far to test this:

$apiKey          = 'YouNeedYourOwnKey123321$'
$headers         =  @{Authorization=("Bearer {0}" -f $apiKey)}
$allURL          = 'https://api.lifx.com/v1/lights/all'

$lightStateURL = 'https://api.lifx.com/v1/lights/all/state'

$state      = 'on'
$color      = 'red'
$brightness = 0.5
$duration   = 0.0

$payload = [PSCustomObject] ${

    power      = $state,
    color      = $color,
    brightness = $brightness,
    duration   = $duration
}
$jsonPayload = $payload | ConvertTo-Json

$setResults = Invoke-RestMethod -Uri $lightStateURL -Method Put -Headers $headers -Body $jsonPayload -ContentType 'application/json'

Let's go ahead and run that to see if it worked:

This is an easy one to verify!

We can also take a look at the results property of $setResults.

Now that we know that it works, it can be used in a function to control the light.

Here is the code for an example of how to build a function with this information:

$apiKey          = 'YouNeedYourOwnKey123321'
$headers         =  @{Authorization=("Bearer {0}" -f $apiKey)}
$baseURL         = 'https://api.lifx.com/v1/lights'

function Set-LightState { #Begin function Set-LightState
    [cmdletbinding()]
    Param (
        [Parameter(Mandatory)]
        $headers,
        [String]
        $state = 'on',
        [String]
        $color = 'White',
        [Double]
        $brightness = 0.5,
        [Double]
        $duration = 0.0
    )
    
    #Get lights
    $ninjaLights = Invoke-RestMethod -Headers $headers -Uri $allURL

    #If our light shows as connected...
    if ($ninjaLights.Connected) {

        #Set the selected as the ID of the light (per LIFX documentation)
        $selector = $ninjaLights.id
        
        #Construct the URL to include the selector and /state
        $fullURL  = "$baseurl/$selector/state"        

        #Build the payload
        $payload = [PSCustomObject] @{

            power      = $state
            color      = $color
            brightness = $brightness
            duration   = $duration
        }

        #Convert payload to JSON
        $jsonPayload = $payload | ConvertTo-Json

        Write-Host "Changing light from:" 
        Write-Host `t"State     : $($ninjaLights.Power)" 
        Write-Host `t"Color     : $($ninjaLights.Color)"
        Write-Host `t"Brightness: $($($ninjaLights.Brightness * 100))%"`n

        Write-Host "Changing light to:" 
        Write-Host `t"State     : $state" 
        Write-Host `t"Color     : $color"
        Write-Host `t"Brightness: $($brightness * 100)%" 
        Write-Host `t"Duration  : $duration"`n

        #Store results in $setResults
        $setResults = Invoke-RestMethod -Uri $fullURL -Method Put -Headers $headers -Body $jsonPayload -ContentType 'application/json'
    
        Write-Host "API status:"
        Write-Host `t"Light :" $setResults.results.label
        Write-Host `t"Status:" $setResults.results.status `n
    
    }

} #End function Set-LightState

Set-LightState -headers $headers -color blue

Let's see what happens when we run it! 

(NOTE) I use Write-Host here merely as a tool to give you a visual of the data you are working with. Ultimately it would be best to create some custom objects, and return the results as you need to with the code you're writing.

The information here is provided to show you what you can do, how you can build a payload, and then work with the data you are returned. You can do anything you put your mind to, from silly things (the next example), to even using it with another script (that may get weather information), and then flash the light red a few times if there is a weather alert.

For this next example, I will utilize a loop, and our new function, to confuse my girlfriend. Well, with all the testing I've been doing... maybe not anymore ;) 

Here's the code:

[array]$colors   = @('white','red','orange','yellow','cyan','green','blue','purple','pink')

$originalBrightness = $ninjaLights.Brightness
$originalColor      = $ninjalights.Color
$originalState      = $ninjalights.Power
$colorString        = "hue:" + $originalcolor.hue + " saturation:" + $originalcolor.saturation + " kelvin:" + $originalColor.Kelvin
        
$i = 0
        
While ($i -le 20) {
            
    $color              = Get-Random $colors
    [double]$brightness = "{0:n2}" -f (Get-Random -Maximum 1 -Minimum 0.00)
 
    Set-LightState -headers $headers -color $color -brightness $brightness -state $state
    Start-Sleep -seconds 1

    $i++
            
}
        
Set-LightState -headers $headers -state $originalState -color $colorString -brightness $originalBrightness

And off we go!

It worked! 

At the end it will set the light back to the value it was discovered with:

If you'd like a deeper dive into this, check out my post on using the Lifx API with PowerShell, here: http://www.gngrninja.com/script-ninja/2016/2/6/powershell-control-your-lifx-light

Username/Password in Request URL (PRTG API)

Some APIs authenticate you via including a username/password (hopefully eventually password hash) in the request URL. PRTG has one of those APIs. PRTG is one of my favorite monitoring tools, as not only is it great out of the box, but it also has great synergy with PowerShell.

PRTG information:

Let's start by getting the credentials of the account you want to use with the API. This account will be an account that has access to do what you need to do in PRTG. I will use the PRTG admin account, but you'll want to ensure you use one setup just to use with the API.

$prtgCredential = Get-Credential

If you're demoing PRTG, and are using a self-signed cert, you'll need the following code to allow Invoke-RestMethod to work with the self-signed cert. This code will only affect your current session.

Add-Type @"
using System.Net;
using System.Security.Cryptography.X509Certificates;

    public class TemporarySelfSignedCert : ICertificatePolicy {
        public TemporarySelfSignedCert() {}
        public bool CheckValidationResult(
            ServicePoint sPoint, X509Certificate cert,
            WebRequest wRequest, int certProb) {
                return true;
            }
    }
"@

[System.Net.ServicePointManager]::CertificatePolicy = New-Object TemporarySelfSignedCert

Now let's setup a variable that contains our PRTG hostname, and a custom object with information for the API URLs.

$hostName = 'localhost'
$prtgInfo = [PSCustomObject]@{

    BaseURL        = "https://$hostName/api"
    SensorCountURL = "https://$hostName/api/table.xml?content=sensors&columns=sensor"
    
}

Next, per PRTG's documenation, we'll construct the URL we need to use to get the password hash.

$getHashURL = "$($prtgInfo.BaseURL)/getpasshash.htm?username=$($prtgCredential.userName)&password=$($prtgCredential.GetNetworkCredential().Password)"

Now we can finally use Invoke-RestMethod to get the hash:

$getHash    = Invoke-RestMethod -Uri $getHashURL

Let's make sure $getHash has our password hash.

Alright, got it! Now we can create a new PS Credential object to store our username, and password hash (instead of our password).

First. we'll convert the hash we created into a secure string. Then, we will create a new PS Credential object using the username we specified in $prtgCredential, and the secure string we just created as the password.

$passwordHash        = ConvertTo-SecureString  $getHash -AsPlainText -Force
$prtgCredentialwHash = New-Object System.Management.Automation.PSCredential ($($prtgCredential.UserName), $passwordHash)

We can then verify the information by displaying the contents of $prtgCredentialwHash, and also using the GetNetworkCredential().Password method.

Now that we know $prtgCredentialwHash contains what we need, we can construct a URL to test out the API.

Let's set some variables up:

$prtgUser         = $prtgCredentialwHash.UserName
$prtgPass         = $prtgCredentialwHash.GetNetworkCredential().Password

$credentialAppend = "&username=$prtgUser&passhash=$prtgPass"

We can check the value of $credentialAppend to ensure it is correct:

Now to construct the URL, and test out the API via Invoke-RestMethod.

Invoke-RestMethod -uri ($prtgInfo.SensorCountURL + $credentialAppend)

Note: The full URL that is constructed is (I cut out my actual password hash on purpose, you'll see your hash after the = if all went well):

If all went well, you will see the results of the request (if not, you'll see an unauthorized message).

Success!

Sending Data via URL (PRTG API)

The PRTG API accepts data in the URL of the request. The below example will pause a sensor for a specific duration, with a specific message:

#Set the $duration (in minutes)
$duration = 10
#Set $sensorID to the sensor we want to pause
$sensorID = 45
#Set the pause message
$message  = "Paused via PS PRTG"

#Construct the $pauseURL
$pauseURL     = "$($prtgInfo.baseURL)/pauseobjectfor.htm?id=$sensorID&pausemsg=$message&duration=$duration"

#Use Invoke-RestMethod with the $pauseURL + $credentialAppend
#We will always append $credentialAppend as this is how the API accepts authentication
Invoke-RestMethod -Uri ($pauseURL + $credentialAppend)

Awesome, it worked!

Mega Example With Concept Code

This last example contains some concept code for a project I'm working on. Feel free to judge and use it as you wish, however I will note now that it is nowhere near finalized. I'm still in the exploration, see what's possible, and try to get it all to work phase. 

Requirements for concept code to work:

  • PRTG Installaction
  • Folder structure
  • Module
    • NinjaLogging.psm1 (included in ZIP file below)

NOTE: I have not fully cleaned up or officially released any of the code yet. That includes the logging module. 

Here is my TODO list for the code:

  • Upload it to GitHub
  • Clean up/add error handling
  • Create readme files
  • Add parameter sets to some of the psprtg.ps1 functions
  • Fully convert psprtg.ps1 to a module
  • Add functionality to psprtg.ps1 to include an 'oh crap!' undo feature
    • This will include exporting a custom object with the device ID and action taken

With that said, here is a link to download it (or you can skip to the code if that's all you want to see):

psPRTG download

How to get it working/examples:

  • Import the script as a module via Import-Module .\psprtg.ps1
    • The first time you do this, it will ask for your PRTG credentials. The module will then export the credential object to the input folder, and then use that exported credential the next time the module is used
  • Contents of machine/user encrypted credential file stored in the input directory:

 

  • Find a device and pause it via Invoke-SensorModification -findDevice
    • Note: You can also specify -Duration and -Message
  • Use a text file list of devices to find and take an action on

Pausing example:

Resuming example:

Log File Contents:

Code

psprtg.ps1

#Setup
$scriptPath      = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition
$moduleDir       = "$scriptPath\modules"
$logDir          = "$scriptPath\logs"
$outputDir       = "$scriptPath\output"
$inputDir        = "$scriptPath\input"
$hashFile        = "$inputDir\prtgCredentialwHash.xml"
$hostName        = 'localhost'
$modules         = ('ninjaLogging')

$prtgInfo = [PSCustomObject]@{

    BaseURL        = "https://$hostName/api"
    SensorCountURL = "https://$hostName/api/table.xml?content=sensors&columns=sensor"
    
}

function Invoke-ModuleImport { #Begin function Invoke-ModuleImport
    [cmdletbinding()]
    param($modules)

    Push-Location

    ForEach ($module in $modules) {

        if (Get-Module -ListAvailable -Name $module) {
    
            Import-Module $module
    
        } elseif (!(Get-Module -Name $module)) {
              
            Write-Verbose "Importing module: [$module] from: [$moduleDir\$module]" 
            Import-Module "$moduleDir\$module"

        } Else {

            Write-Verbose "Module [$module] already loaded!"

        }

    }

    Pop-Location

} #End function Invoke-ModuleImport

function Invoke-CredentialCheck { #Begin function Invoke-CredentialCheck
    [cmdletbinding()]
    Param()

    Try { #Begin try for credential path existence

        #Check if our hashed credential object exists
        if (Test-Path $hashFile) { #Begin if for testing credential path existence 

            #Import credentials from hash file

            Write-LogFile -logPath $logFile -logValue "Importing credentials from [$hashFile]" -Verbose

            $prtgCredentialwHash = Import-Clixml $hashFile

            #Add to info hash

            $prtgInfo | Add-Member -Type NoteProperty -Name Credentials -Value $prtgCredentialwHash

            #create credential string for URLs
            $prtgUser         = $prtgCredentialwHash.UserName
            $prtgPass         = $prtgCredentialwHash.GetNetworkCredential().Password

            $credentialAppend = "&username=$prtgUser&passhash=$prtgPass"
            
            Return $credentialAppend

        #If it doesn't exist, attempt to create it
        } else {

            Write-Host "We need to get your hashed password to use with the API!" `n
            Write-Host "Please enter your PRTG credentials"`n

            #Store credential in $prtgCredential
            $prtgCredential = Get-Credential

            #Get the password hash via ConvertTo-SecureString and the Get-PRTGPasswordHash function.
            #Note: If you are not using a self-signed certificate (hopefully not, but if it is a demo/test install you may be): use -selfSignedCert:$True
            $passwordHash   = ConvertTo-SecureString  "$(Get-PRTGPasswordHash -PRTGCredential $prtgCredential -selfSignedCert:$True)" -AsPlainText -Force

            #Create new credential object and export it to the input folder
            #This allows us to use it when the script runs
            $prtgCredentialwHash = New-Object System.Management.Automation.PSCredential ($($prtgCredential.UserName), $passwordHash)

            Write-LogFile -logPath $logFile -logValue "Exporting hashed credentials to [$hashFile]." -Verbose

            #Export to file
            $prtgCredentialwHash | Export-Clixml $hashFile

            #create credential string for URLs
            $prtgUser         = $prtgCredentialwHash.UserName
            $prtgPass         = $prtgCredentialwHash.GetNetworkCredential().Password

            $credentialAppend = "&username=$prtgUser&passhash=$prtgPass"

            Return $credentialAppend

        }  #End if for testing credential path existence

    } #End try for credential path existence

    Catch {

        $errorMessage = $_.Exception.Message

        Write-LogFileError -logPath $logFile -errorDesc "Error while checking for credentials/importing credentials: [$errorMessage]!"
    
        #Resolve the log file
        Resolve-LogFile -logPath $logFile

        Break

    }

} #End function Invoke-CredentialCheck

function Get-PRTGPasswordHash { #Begin function Get-PRTGPasswordHash
    [cmdletbinding()]
    param(
        [Parameter(Mandatory)]
        [PSCredential]
        $PRTGCredential,

        [Parameter()]
        [Boolean]
        $selfSignedCert = $false
    )

    if ($selfSignedCert) {

        Try {

            Add-Type @"
                using System.Net;
                using System.Security.Cryptography.X509Certificates;

                    public class TemporarySelfSignedCert : ICertificatePolicy {
                    public TemporarySelfSignedCert() {}
                    public bool CheckValidationResult(
                        ServicePoint sPoint, X509Certificate cert,
                        WebRequest wRequest, int certProb) {
                        return true;
                    }
                }
"@

            [System.Net.ServicePointManager]::CertificatePolicy = New-Object TemporarySelfSignedCert

    }

        Catch {
        
            $errorMessage = $_.Exception.Message

            Write-LogFileError -logPath $logFile -errorDesc "Error while allowing self signed certs: [$errorMessage]" -ForegroundColor Red -BackgroundColor DarkBlue
            
            #Resolve the log file
            Resolve-LogFile -logPath $logFile

            Break


        }

    }

    Try {

        $getHashURL = "$($prtgInfo.BaseURL)/getpasshash.htm?username=$($prtgCredential.userName)&password=$($prtgCredential.GetNetworkCredential().Password)"
        $getHash    = Invoke-RestMethod -Uri $getHashURL 

        Return $getHash

    }

    Catch {

        $errorMessage = $_.Exception.Message

        Write-LogFileError -logPath $logFile -errorDesc "Error while getting hash: [$errorMessage]" -Verbose

        Break

    }

} #End function Get-PRTGPasswordHash

function Invoke-DeviceSearch { #Begin function Invoke-DeviceSearch
    [cmdletbinding()]
    Param(
        [Parameter(Mandatory)]
        $findMe
    )

    [System.Collections.ArrayList]$foundDeviceIDs = @()

    ForEach ($find in $findMe) {

        Try {

            $getDeviceURL       = "$($prtgInfo.BaseURL)/table.json?content=devices&output=json&columns=objid,probe,group,device,host,downsens,partialdownsens,downacksens,upsens,warnsens,pausedsens,unusualsens,undefinedsens"
            $devices            = Invoke-RestMethod -Uri ($getDeviceURL + $credentialAppend) 
            $findDeviceWildCard = '*' + $find + '*'

            ForEach ($device in $devices.devices) {

                Switch ($device.device) {

                    {$_ -like $findDeviceWildCard} {
                    
                        $foundDeviceIDs.Add($device.objid) | Out-Null
                    
                    }

                }

            } 
        
        }

        Catch {

            $errorMessage = $_.Exception.Message

            Write-LogFileError -logPath $logFile -errorDesc "Unable to get device list: [$errorMessage]" -Verbose

        }

    }

    Return $foundDeviceIDs

} #End function Invoke-DeviceSearch

function Invoke-SensorModification { #Begin function Invoke-SensorModification
    [cmdletbinding()]
    Param(
        [parameter(Mandatory)]
        [string]
        $action,
        [parameter()]
        [string]
        $findDevice,
        [Parameter()]
        [int]
        $sensorID,
        [Parameter()]
        [String]
        $message,
        [Parameter()]
        [int]
        $duration,
        [Parameter()]
        $List
    )

    $logFile = New-logFile -logPath $logDir -logName 'PSPRTG.log' -addDate:$true

    if (!$duration) {

        $duration = 30

    }

    if (!$message) {

        $message = "Sensor paused by: [$((Get-ChildItem Env:\USERNAME).Value)] via PS PRTG."    

    }

    if ($List) {
        
        Switch ($List | Get-Member | Select-Object -ExpandProperty TypeName -Unique) {

            {$_ -eq 'System.String'} {

                if ($list -like '*.txt') {
                
                    Write-Host "File detected" 

                    $deviceList = Get-Content $list

                    $foundDeviceIDs = Invoke-DeviceSearch -findMe $deviceList

                } else {

                    Write-Host 'Single string or array detected'

                    $deviceList = $list 

                    $foundDeviceIDs = Invoke-DeviceSearch -findMe $deviceList

                }

            }

            {$_ -eq 'System.Management.Automation.PSCustomObject'} {

                Write-Host 'Custom Object detected'

            }

        }

    }

    if ($findDevice) {

        Try {
            
            $foundDeviceIDs = Invoke-DeviceSearch -findMe $findDevice
        
        }

        Catch {

            $errorMessage = $_.Exception.Message

            Write-LogFileError -logPath $logFile -errorDesc "Unable to get device list: [$errorMessage]" -Verbose
            
            #Resolve the log file
            Resolve-LogFile -logPath $logFile

            Break
        
        }

    }

    Switch ($action) { #Begin switch for PRTG action

        {$_ -eq 'Pause'} {

            if ($findDevice -or $List) {

                ForEach ($id in $foundDeviceIDs) {

                    $sensorID     = $null
                    $sensorID     = $id

                    $pauseURL     = "$($prtgInfo.baseURL)/pauseobjectfor.htm?id=$sensorID&pausemsg=$message&duration=$duration"
        
                    Write-LogFile -logPath $logFile -Value "Attempting to pause sensor ID: [$sensorID], for duration of [$duration], with message [$message]" -Verbose
            
                    $pauseAttempt = Invoke-WebRequest -Uri ($pauseURL + $credentialAppend)   
                
                } 

                #Resolve the log file
                Resolve-LogFile -logPath $logFile 

            } 
            
            Else { 

                $pauseURL     = "$($prtgInfo.baseURL)/pauseobjectfor.htm?id=$sensorID&pausemsg=$message&duration=$duration"
        
                Write-LogFile -logPath $logFile -Value "Attempting to pause sensor ID: [$sensorID], for duration of [$duration], with message [$message]" -Verbose
            
                $pauseAttempt = Invoke-WebRequest -Uri ($pauseURL + $credentialAppend)

                #Resolve the log file
                Resolve-LogFile -logPath $logFile 
            

            }

        }

        {$_ -eq 'Resume'} {

            if ($findDevice -or $List) {

                ForEach ($id in $foundDeviceIDs) {

                    $sensorID     = $null
                    $sensorID     = $id

                    $resumeURL     = "$($prtgInfo.baseURL)/pause.htm?id=$sensorID&action=1"
        
                    Write-LogFile -logPath $logFile -Value "Attempting to resume sensor ID: [$sensorID]." -Verbose
            
                    $resumeAttempt =  Invoke-WebRequest -Uri ($resumeURL + $credentialAppend)
                
                }
                
                #Resolve the log file
                Resolve-LogFile -logPath $logFile 

            } 
            
            Else { 

                $resumeURL     = "$($prtgInfo.baseURL)/pause.htm?id=$sensorID&action=1"
        
                Write-LogFile -logPath $logFile -Value "Attempting to resume sensor ID: [$sensorID]." -Verbose
            
                $resumeAttempt =  Invoke-WebRequest -Uri ($resumeURL + $credentialAppend)
            
                #Resolve the log file
                Resolve-LogFile -logPath $logFile 

            }

        }

        Default {

            Write-LogFileError -logPath $logFile -errorDesc "Unable to perform action [$action], as it does not match any valid actions!" -Verbose

            Resolve-LogFile -logPath $logFile 

            Break

        }

    } #End switch for PRTG action

} #End function Invoke-SensorModification

#Test API
Try{

    #Import modules 
    ForEach ($module in $modules) {Invoke-ModuleImport -modules $module}

    #Create log file
    $logFile = New-logFile -logPath $logDir -logName 'PSPRTG.log' -addDate:$true

    #Attempt to import/set credentials and get the returned string to append to the request for authentication
    $credentialAppend = Invoke-CredentialCheck
    
    #Attempt to get information as a test to see if the API will work
    Invoke-RestMethod -uri ($prtgInfo.SensorCountURL + $credentialAppend) | Out-Null

    #If it works, set TestPassed as true
    $prtgInfo | Add-Member -MemberType NoteProperty -Name TestPassed -Value $true
    
}

Catch {

    $errorMessage = $_.Exception.Message
    
    Write-LogFileError -logPath $logFile -errorDesc "Error while attempting to use API: [$errorMessage]" -Verbose

    #If it doesn't work, set TestPassed as false and break out
    $prtgInfo | Add-Member -MemberType NoteProperty -Name TestPassed -Value $false

    #Resolve the log file
    Resolve-LogFile -logPath $logFile

    Break

}

NinjaLogging.psm1

Set-StrictMode -Version Latest

$scriptPath = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition

Switch ($MyInvocation.PSCommandPath) {

    {$_ -match '\\\\'} {
    
    
        $scriptName = $_.SubString($_.LastIndexOf('\')+1)
    
    }

    Default { 
    
        $scriptName = Get-ChildItem $MyInvocation.PSCommandPath | Select-Object -ExpandProperty BaseName
        
    }

}

if ('Count' -in ($scriptName.psobject.Members.Name)) {
    
    $scriptName = 'ScriptLog'
    
}

function New-LogFile {
<#
.SYNOPSIS
   New-LogFile will create a log file.

.DESCRIPTION
   New-LogFile will create a log file. 

   You can specify different paramaters to change the file's name, and where it is stored.
   By default it will attempt to get the name of the calling function or script via $scriptName = (Get-ChildItem $MyInvocation.PSCommandPath | Select-Object -ExpandProperty BaseName).
   It will also attempt to get the path via $scriptPath = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition.
   You can also specify the path and name, as well as if you'd like to append the date in the following format: MM-dd-yy_HHmm.

   Use the -Verbose parameter to display what is happening to the host.

.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the path to the logfile

.PARAMETER logName    
    Alias: Name
    Type : String

    Specify the name of the log file. Be sure to include the extension if specifying the name.

.PARAMETER scriptVersion
    Type : Double

    Specify the version of your script being run. If left blank, will default to 0.1

.PARAMETER addDate
    Type : Boolean

    Specify if you'd like to add the date to the file name.  If you're specifying logName, you can use addDate to append the current date/time in the format: MM-dd-yy_HHmm.

.NOTES
    Name: New-LogFile
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16
    

.LINK
    http://www.gngrninja.com

.EXAMPLE
    $logFile = New-LogFile 
    -----------------------------
    
    gngrNinja> $logFile
    C:\PowerShell\logs\ScriptLog_05-11-16_1612.log

.EXAMPLE
    $logFile = New-LogFile -Verbose
    -----------------------------
    
    VERBOSE: No path specified. Using: C:\PowerShell\logs
    VERBOSE:
    VERBOSE: No log name specified. Setting log name to: ScriptLog.log and adding date.
    VERBOSE:
    VERBOSE: Adding date to log file with an extension! New file name:
    ScriptLog_05-11-16_1613.log
    VERBOSE:
    VERBOSE: Created C:\PowerShell\logs\ScriptLog_05-11-16_1613.log
    VERBOSE:
    VERBOSE: File C:\PowerShell\logs\ScriptLog_05-11-16_1613.log created and verified to
    exist.
    VERBOSE:
    VERBOSE: Adding the following information to:
    C:\PowerShell\logs\ScriptLog_05-11-16_1613.log
    VERBOSE:
    VERBOSE: -----------------------------------------------------------------
    VERBOSE: Started logging at [05/11/2016 16:13:11]
    VERBOSE: Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    VERBOSE: -----------------------------------------------------------------
    VERBOSE:
    gngrNinja>

.EXAMPLE
    $logfile = New-LogFile -Name 'testName.log' -path  'c:\temp' -addDate $true -Verbose
    -----------------------------

    VERBOSE: Adding date to log file with an extension! New file name:
    testName_05-11-16_1615.log
    VERBOSE:
    VERBOSE: Created c:\temp\testName_05-11-16_1615.log
    VERBOSE:
    VERBOSE: File C:\temp\testName_05-11-16_1615.log created and verified to exist.
    VERBOSE:
    VERBOSE: Adding the following information to: C:\temp\testName_05-11-16_1615.log
    VERBOSE:
    VERBOSE: -----------------------------------------------------------------
    VERBOSE: Started logging at [05/11/2016 16:15:13]
    VERBOSE: Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    VERBOSE: -----------------------------------------------------------------
    VERBOSE:

.OUTPUTS
    Full path to the log file created.
#>
    [cmdletbinding()]
    param(
        [Parameter(Mandatory = $false,
                   Position  = 0)]
        [Alias('Path')]
        [string]
        $logPath,
        [Parameter(Mandatory = $false,
                   Position  = 1)]
        [Alias('Name')]
        [string]
        $logName,
        [Parameter(Mandatory = $false,
                   Position  = 2)]
        [double]
        $scriptVersion = 0.1,
        [Parameter(Mandatory = $false,
                   Position  = 3)]
        [boolean]
        $addDate = $false
    )

#Check if file/path are set
    if (!$logPath) {
        
        $logPath = "$scriptPath\logs"
        
        Write-Verbose "No path specified. Using: $logPath"
        Write-Verbose ""
        
    }
    
    if (!$logName) {
        
        $logName = $scriptName + '.log'
        $addDate = $true
        
        Write-Verbose "No log name specified. Setting log name to: $logName and adding date."
        Write-Verbose ""
      
    }

    #Check if $addDate is $true, take action if so
    if ($addDate) {
        
        if ($logName.Contains('.')) {
            
            $logName = $logName.SubString(0,$logName.LastIndexOf('.')) + "_{0:MM-dd-yy_HHmm}" -f (Get-Date) + $logName.Substring($logName.LastIndexOf('.'))
            
            Write-Verbose "Adding date to log file with an extension! New file name: $logName"
            Write-Verbose ""
           
        } else {
            
            $logName = $logName + "_{0:MM-dd-yy_HHmm}" -f (Get-Date)
            
            Write-Verbose "Adding date to log file. New file name: $logName"
            Write-Verbose ""
            
        }
         
    }
    
    #Variable set up
    $time     = Get-Date
    $fullPath = $logPath + '\' + $logName
    $curUser  = (Get-ChildItem Env:\USERNAME).Value
    $curComp  = (Get-ChildItem Env:\COMPUTERNAME).Value
    
    #Checking paths / Creating directory if needed
    
    if (!(Test-Path $logPath)) {
        
        Try {
            
            New-Item -Path $logPath -ItemType Directory -ErrorAction Stop | Out-Null
            
            Write-Verbose "Folder $logPath created as it did not exist."
            Write-Verbose ""
            
        }
        
        Catch {
            
            $message = $_.Exception.Message
            
            Write-Output "Could not create folder due to an error. Aborting. (See error details below)"
            Write-Error $message
            
            Break
          
        }
    
    }
    
    #Checking to see if a file with the name name exists, renaming it if so.
    if (Test-Path $fullPath) {
        
        Try {
            
            $renFileName = ($fullPath + (Get-Random -Minimum ($time.Second) -Maximum 999) + 'old')
            
            Rename-Item $fullPath -NewName ($renFileName.Substring($renFileName.LastIndexOf('\')+1)) -Force -ErrorAction Stop | Out-Null
            
            Write-Verbose "Renamed $fullPath to $($renFileName.Substring($renFileName.LastIndexOf('\')+1))"
            Write-Verbose ""
            
        }
        
        Catch {
            
            $message = $_.Excetion.Message
            
            Write-Output "Could not rename existing file due to an error. Aborting. (See error details below)"
            Write-Error $message
            
            Break
            
        }
        
    }
    
    #File creation
    Try {
        
        New-Item -Path $fullPath -ItemType File -ErrorAction Stop | Out-Null
        
        Write-Verbose "Created $fullPath"
        Write-Verbose ""
        
    } 
    
    Catch {
        
        $message = $_.Exception.Message
        
        Write-Output "Could not create directory due to an error. Aborting. (See error details below)"
        Write-Error $message
        
        Break
        
    }
    
    #Get the full path in case of dot sourcing
    $fullPath = (Get-ChildItem $fullPath).FullName
    
    if (Test-Path $fullPath) {
        
        $flairLength = ("Script (Version $scriptVersion) executed by: [$curUser] on computer: [$curComp]").Length + 1
        
        Write-Verbose "File $fullPath created and verified to exist."
        Write-Verbose ""
        Write-Verbose "Adding the following information to: $fullPath"
        Write-Verbose ""
        Write-Verbose ('-'*$flairLength)
        Write-Verbose "Started logging at [$time]"
        Write-Verbose "Script (Version $scriptVersion) executed by: [$curUser] on computer: [$curComp]"
        Write-Verbose ('-'*$flairLength)
        Write-Verbose ""
        
        Add-Content -Path $fullPath -Value ('-'*$flairLength)
        Add-Content -Path $fullPath -Value "Started logging at [$time]"
        Add-Content -Path $fullPath -Value "Script (Version $scriptVersion) executed by: [$curUser] on computer: [$curComp]"
        Add-Content -Path $fullPath -Value ('-'*$flairLength)
        Add-Content -Path $fullPath -Value ""
        
        Return [string]$fullPath
         
    } else {
        
        Write-Error "File $fullPath does not exist. Aborting script."

        Break
        
    }
       
}

function Write-LogFile {
<#
.SYNOPSIS
   Write-LogFile will add information to a log file created with New-LogFile.

.DESCRIPTION
   Write-LogFile will add information to a log file created with New-LogFile.

   By default additions to the log file will include a timestamp, unless you specify -addTimeStamp $false.
   This function accepts values from the pipeline, as demonstrated in an example.

   Use the -Verbose parameter to display what is being logged to the host.
   
.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the full path to the log file, including the name.

.PARAMETER logValue
    Alias: Value
    Type : String

    Specify the value(s) you'd like logged.

.PARAMETER addTimeStamp
    Type : Boolean

    Defaults to true, set to false if you'd like to omit the timestamp.

.NOTES
    Name: Write-LogFile
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16

.LINK
    http://www.gngrninja.com

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFile -logPath $logFile -logValue 'test log value!'
    -----------------------------

    gngrNinja> more $logfile
    -----------------------------------------------------------------
    Started logging at [05/11/2016 16:19:37]
    Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    -----------------------------------------------------------------

    [05-11-16 16:23:24] test log value!

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFile -logPath $logFile -logValue 'test log value!' -Verbose
    -----------------------------
    
    VERBOSE: Adding [05-11-16 16:25:19] test log value! to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Get-Process | Write-LogFile $logFile -Verbose
    -----------------------------

    ...
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (wininit) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (winlogon) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (WmiPrvSE) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (WmiPrvSE) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (WUDFHost) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    ...

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFile -logPath $logFile -logValue 'test without timestamp' -addTimeStamp $false -Verbose
    -----------------------------

    VERBOSE: Adding test without timestamp to C:\PowerShell\logs\ScriptLog_05-11-16_1631.log
    VERBOSE:
#>
    [cmdletbinding()]
    param(
        [Parameter(Mandatory = $true,
                   Position  = 0)]
        [Alias('Path')]
        [string]
        $logPath,
        [Parameter(Mandatory                       = $true,
                   ValueFromPipeline               = $true,
                   ValueFromPipelineByPropertyName = $true,
                   Position                        = 1)]
        [Alias('Value')]
        [string]
        $logValue,
        [Parameter(Mandatory = $false,
                   Position  = 2)]
        [boolean]
        $addTimeStamp = $true
    )
    
    Begin {

        if (!(Test-Path $logPath)) {
        
            Write-Error "Unable to access $logPath"

            Break
        
        } 

    }

    Process {

        ForEach ($value in $logValue) {
        
            $timeStamp = "[{0,0:MM}-{0,0:dd}-{0,0:yy} {0,0:HH}:{0,0:mm}:{0,0:ss}]" -f (Get-Date)

            if ($addTimeStamp) {
            
                $value = "$($timeStamp + ' ' + $value)"
           
            }
        
            Write-Verbose "Adding $value to $logPath"
            Write-Verbose ""
        
            Add-Content -Path $logPath -Value $value
            Add-Content -Path $logPath -Value ''

        }
         
    }

}

function Write-LogFileError {
<#
.SYNOPSIS
   Write-LogFileError will add information to a log file created with New-LogFile. The information will be prepended with [ERROR].

.DESCRIPTION
   Write-LogFileError will add information to a log file created with New-LogFile. The information will be prepended with [ERROR].

   By default additions to the log file will include a timestamp, unless you specify -addTimeStamp $false.
   This function accepts values from the pipeline, as demonstrated in an example.

   Use the -Verbose parameter to display what is being logged to the host.
   
.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the full path to the log file, including the name.

.PARAMETER errorDesc
    Alias: Value
    Type : String

    Specify the value(s) you'd like logged as errors.

.PARAMETER addTimeStamp
    Type : Boolean

    Defaults to true, set to false if you'd like to omit the timestamp.

.PARAMETER exitScript
    Alias: Exit
    Type : Boolean

    This parameter let's you specify $true if you'd like to exit the script after the error is logged. 
    It defaults to $false.
    
.NOTES
    Name: Write-LogFileError
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16

.LINK
    http://www.gngrninja.com

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFileError -logPath $logFile -errorDesc 'test log value error!'
    -----------------------------

    gngrNinja> more $logFile
    -----------------------------------------------------------------
    Started logging at [05/11/2016 16:31:44]
    Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    -----------------------------------------------------------------

    [05-11-16 16:31:51] [ERROR ENCOUNTERED]: test log value error!
#>
    [CmdletBinding()]
        param(
            [Parameter(Mandatory = $true,
                       Position  = 0)]
            [Alias('Path')]
            [string]
            $logPath,
            [Parameter(Mandatory                       = $true,
                       ValueFromPipeline               = $true,
                       ValueFromPipelineByPropertyName = $true,
                       Position                        = 1)]
            [string]
            $errorDesc,
            [Parameter(Mandatory = $false,
                       Position  = 2)]
            [boolean]
            $addTimeStamp = $true,
            [Parameter(Mandatory = $false,
                       Position  = 3)]
            [Alias('Exit')]           
            [boolean]
            $exitScript = $false
        )
    
    Begin {

        if (!(Test-Path $logPath)) {
    
            Write-Error "Unable to access $logPath"
            Break

        }

    }

    Process {
     
        ForEach ($value in $errorDesc) { 

            $timeStamp = "[{0,0:MM}-{0,0:dd}-{0,0:yy} {0,0:HH}:{0,0:mm}:{0,0:ss}]" -f (Get-Date)

            $value     = "[ERROR ENCOUNTERED]: $value"
        
            if ($addTimeStamp) {
            
                $value = "$($timeStamp + ' ' + $value)"
           
            }
        
            Write-Verbose "Adding $value to $logPath"
            Write-Verbose ""
        
            Add-Content -Path $logPath -Value $value
            Add-Content -Path $logPath -Value ''

        }

    }

    End {
         
         if ($exitScript) {
            
            Write-Verbose "Performing log file close command: Resolve-LogFile -logPath $logPath -exitonCompletion $true"
            Write-Verbose ""
            
            Resolve-LogFile -logPath $logPath -exitScript $true
            
        }

    }
 
    
    
}

function Write-LogFileWarning {
<#
.SYNOPSIS
   Write-LogFileWarning will add information to a log file created with New-LogFile. The information will be prepended with [ERROR].

.DESCRIPTION
   Write-LogFileWarning will add information to a log file created with New-LogFile. The information will be prepended with [ERROR].

   By default additions to the log file will include a timestamp, unless you specify -addTimeStamp $false.
   This function accepts values from the pipeline, as demonstrated in an example.

   Use the -Verbose parameter to display what is being logged to the host.
   
.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the full path to the log file, including the name.

.PARAMETER warningDesc
    Alias: Value
    Type : String

    Specify the value(s) you'd like logged as errors.

.PARAMETER addTimeStamp
    Type : Boolean

    Defaults to true, set to false if you'd like to omit the timestamp.

.PARAMETER exitScript
    Alias: Exit
    Type : Boolean

    This parameter let's you specify $true if you'd like to exit the script after the error is logged. 
    It defaults to $false.

.NOTES
    Name: Write-LogFileWarning
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16

.LINK
    http://www.gngrninja.com

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFileWarning -logPath $logFile -warningDesc 'test log value warning!'
    -----------------------------

    gngrNinja> more $logFile
    -----------------------------------------------------------------
    Started logging at [05/11/2016 16:38:29]
    Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    -----------------------------------------------------------------

    [05-11-16 16:38:48] [WARNING]: test log value warning!
#>
    [CmdletBinding()]
        param(
            [Parameter(Mandatory = $true,
                       Position  = 0)]
            [Alias('Path')]
            [string]
            $logPath,
            [Parameter(Mandatory                       = $true,
                       ValueFromPipeline               = $true,
                       ValueFromPipelineByPropertyName = $true,
                       Position                        = 1)]
            [string]
            $warningDesc,
            [Parameter(Mandatory = $false,
                       Position  = 2)]
            [boolean]
            $addTimeStamp = $true,
            [Parameter(Mandatory = $false,
                       Position  = 3)]
            [Alias('Exit')]           
            [boolean]
            $exitScript = $false
        )
    
    Begin {

        if (!(Test-Path $logPath)) {
    
            Write-Error "Unable to access $logPath"

            Break

        }

    }

    Process {
     
        ForEach ($value in $warningDesc) { 

            $timeStamp = "[{0,0:MM}-{0,0:dd}-{0,0:yy} {0,0:HH}:{0,0:mm}:{0,0:ss}]" -f (Get-Date)

            $value     = "[WARNING]: $value"
        
            if ($addTimeStamp) {
            
                $value = "$($timeStamp + ' ' + $value)"
           
            }
        
            Write-Verbose "Adding $value to $logPath"
            Write-Verbose ""
        
            Add-Content -Path $logPath -Value $value
            Add-Content -Path $logPath -Value ''

        }

    }

    End {
         
         if ($exitScript) {
            
            Write-Verbose "Performing log file close command: Resolve-LogFile -logPath $logPath -exitonCompletion $true"
            Write-Verbose ""
            
            Resolve-LogFile -logPath $logPath -exitScript $true
            
        }

    }
    
}

function Resolve-LogFile {
<#
.SYNOPSIS
   Resolve-LogFile will resolve a created log file.

.DESCRIPTION
   Resolve-LogFile will resolve a created log file.

   Use the -Verbose parameter to display what is happening to the host.

.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the full path, including name, to the log file to be resolved.

.PARAMETER logName    
    Alias: Name
    Type : String

    Specify the name of the log file. Be sure to include the extension if specifying the name.

.PARAMETER exitScript
    Alias: Exit
    Type : Boolean

    Specify $true if you'd like to exit the script after the log file is resolved. 
    It defaults to $false.

.NOTES
    Name: Resolve-LogFile
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16
    
.LINK
    http://www.gngrninja.com

.EXAMPLE
    $logFile = New-LogFile 
    -----------------------------
    
    gngrNinja> $logFile
    C:\PowerShell\logs\ScriptLog_05-11-16_1612.log

.EXAMPLE

    $logFile = New-LogFile 
    Get-Process | Write-LogFile $logFile

    Resolve-LogFile $logFile
    -----------------------------
    
    ...
    [05-11-16 16:43:58] System.Diagnostics.Process (wininit)

    [05-11-16 16:43:58] System.Diagnostics.Process (winlogon)

    [05-11-16 16:43:58] System.Diagnostics.Process (WmiPrvSE)

    [05-11-16 16:43:58] System.Diagnostics.Process (WmiPrvSE)

    [05-11-16 16:43:58] System.Diagnostics.Process (WUDFHost)

    ---------------------------------------------
    Ended logging at [05/11/2016 16:44:01]
    ---------------------------------------------
#>
    [cmdletbinding()]
    param(
        [parameter(Mandatory = $true,
                   Position  = 0)]
        [Alias('Path')]
        [string]
        $logPath,
        [Parameter(Mandatory = $false,
                   Position  = 1)]
        [Alias('Exit')]
        [boolean]
        $exitScript = $false
    )
    
    $time = Get-Date
    
    if (Test-Path $logPath) {
        
        $flairLength = ("Finished processing at [$time]").Length + 1
        
        Write-Verbose "Adding the following content to: $logPath"
        Write-Verbose ('-'*$flairLength)
        Write-Verbose "Ended logging at [$time]"
        Write-Verbose ('-'*$flairLength)
        Write-Verbose ""
        
        Add-Content -Path $logPath -Value ('-'*$flairLength)
        Add-Content -Path $logPath -Value "Ended logging at [$time]"
        Add-Content -Path $logPath -Value ('-'*$flairLength)
   
    } else {
        
        Write-Error "Unable to access $logPath"

        Break
        
    }
    
    if ($exitScript) {
        
        Write-Verbose "Exiting on completion specified, exiting..."
        
        Exit
        
    } 
   
}

function Out-LogFile {
<#
.SYNOPSIS
   Out-LogFile will create, add to, and resolve a logfile.

.DESCRIPTION
   Out-LogFile will create, add to, and resolve a logfile.

   Value from the pipeline is accepted.

   Use the -Verbose parameter to display what is happening to the host.

.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the path to the logFile you'd like created.

.PARAMETER logName    
    Alias: Name
    Type : String

    Specify the name of the log file. Be sure to include the extension if specifying the name.

.PARAMETER logValue
    Alias: Value
    Type : String

    Specify the value(s) you'd like logged.

.PARAMETER addTimeStamp
    Type : Boolean

    Defaults to true, set to false if you'd like to omit the timestamp.

.NOTES
    Name: Out-LogFile
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16
    
.LINK
    http://www.gngrninja.com

.EXAMPLE
    $outLog = Get-Process | Out-LogFile -logPath c:\temp -logName 'outlog.log' -Verbose 
    -----------------------------
    
    VERBOSE: Created c:\temp\outlog.log
    VERBOSE:
    VERBOSE: File C:\temp\outlog.log created and verified to exist.
    VERBOSE:
    VERBOSE: Adding the following information to: C:\temp\outlog.log
    VERBOSE:
    VERBOSE: -----------------------------------------------------------------
    VERBOSE: Started logging at [05/11/2016 16:58:43]
    VERBOSE: Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    VERBOSE: -----------------------------------------------------------------
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:58:43] System.Diagnostics.Process (AdobeUpdateService) to
    C:\temp\outlog.log
    VERBOSE:

.EXAMPLE
    $outLog = Get-Process | Out-LogFile -Verbose
    -----------------------------
    
    gngrNinja> more $outLog
    ...
    [05-11-16 16:56:02] System.Diagnostics.Process (winlogon)

    [05-11-16 16:56:02] System.Diagnostics.Process (WmiPrvSE)

    [05-11-16 16:56:02] System.Diagnostics.Process (WmiPrvSE)

    [05-11-16 16:56:02] System.Diagnostics.Process (WUDFHost)

    ---------------------------------------------
    Ended logging at [05/11/2016 16:56:02]
    ---------------------------------------------

gngrNinja>
#>
    [cmdletbinding()]
    param(
        [Parameter(Mandatory = $false,
                   Position  = 0)]
        [Alias('Path')]
        [string]
        $logPath,
        [Parameter(Mandatory = $false,
                   Position  = 1)]
        [Alias('Name')]
        [string]
        $logName,
        [Parameter(Mandatory = $true,
                   ValueFromPipeLine               = $true,
                   ValueFromPipelineByPropertyName = $true,
                   Position                        = 2)]
        [Alias('Value')]
        [string]
        $logValue,
        [Parameter(Mandatory = $false,
                   Position  = 3)]
        [boolean]
        $addTimeStamp = $true
    )
    
    Begin {
        
        $logFile = New-LogFile -logPath $logPath -logName $logName

    }
    
    Process {

        ForEach ($value in $logValue) {
        
            Write-LogFile -logPath $logFile -logValue $value -addTimeStamp $addTimeStamp
        
        }


    }
    
    End {
    
        Resolve-LogFile $logFile 

        Return $logFile

    }
    
}

function Send-LogEmail {
    [cmdletbinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]
        $To,
        [Parameter(Mandatory=$true)]
        [string]
        $Subject,
        [Parameter(Mandatory=$true)]
        [string]
        $Body,
        [Parameter(Mandatory=$true)]
        [string]
        $emailFrom,
        [Parameter(Mandatory=$true)]
        [string]
        $emailUser,
        [Parameter(Mandatory=$false)]
        [string]
        $provider = 'gmail',
        [Parameter(Mandatory=$true)]
        $password = (Read-Host "Password?" -AsSecureString)
    )

    if (!$to)        {Write-Error "No recipient specified";break}
    if (!$subject)   {Write-Error "No subject specified";break}
    if (!$body)      {Write-Error "No body specified";break}
    if (!$emailFrom) {$emailFrom = 'Ninja_PS_Logging@gngrninja.com'}
   
    Switch ($provider) {

        {$_ -eq 'gmail'} {
            
            $SMTPServer   = "smtp.gmail.com"
            $SMTPPort = 587

        }

        {$_ -eq 'custom'} {

            $SMTPServer = 'Your.SMTP.Server'
            $SMTPPort   = 'Your.SMTP.Server.Port'

        }

    }

    $gmailCredential = New-Object System.Management.Automation.PSCredential($emailUser,$password)
    Send-MailMessage -To $to -From $emailFrom -Body $body -BodyAsHtml:$true -Subject $Subject -SmtpServer $smtpServer -Port $smtpPort -UseSsl -Credential $gmailCredential

}

Notes:

  • I've spent some time on the help with the NinjaLogging.psm1 module
    • Feel free to browse through and use it as you wish, let me know if you have any problems!
  • I have not finished the email sending feature
  • This code will eventually find its way to Github, and be a lot more polished

Homework

  • Find an API for an application you'd like control with PowerShell
    • Study up on the documentation, and find ways to send data to it
    • Automate something using what you've learned today

[Back to top]

PowerShell: Getting Started - Utilizing the Web: Part 2 (Invoke-RestMethod)

PowerShell: Getting Started - Utilizing the Web: Part 2 (Invoke-RestMethod)

Getting Started - Utilizing the Web: Part 2 (Invoke-RestMethod)

Invoke-RestMethod

If Invoke-WebRequest had a brother, it would be Invoke-RestMethod. Well… brother, cousin, sister… you get the idea! They are related. Invoke-RestMethod allows us to send requests to REST web services (Representational State Transfer), and then returns to us a lovely object to use as we need to.

If we get an RSS feed as a response, we’ll get XML as the returned data type. When working with APIs, and using Invoke-RestMethod, you’ll get a custom object back. This is because more times than not we'll get the information back as JSON (JavaScript Object Notation). PowerShell automatically converts that into the custom object, which you can then dive into the properties of.

Here are the different methods we can send:

  • Default
  • Delete
  • Get
  • Head
  • Merge
  • Options
  • Patch
  • Post
  • Put
  • Trace

 

Whether we’re working with an RSS feed, or an API of sorts, Invoke-RestMethod allows us to do so with ease. After learning how to use it, it has quickly become one of my favorite commands in PowerShell.

Using Invoke-RestMethod

Let's go into some examples of how to use Invoke-RestMethod. To start out, we will use it to get some information via RSS, and then dive a little deeper into APIs.

RSS Feeds

Invoke-RestMethod can be used to gather information from RSS feeds. For this example, we'll get the feed for http://www.reddit.com/r/powershell. To do that, we simply append .rss to the URL.

Here's the command:

$redditRSS = Invoke-RestMethod 'http://www.reddit.com/r/PowerShell.rss'

Let's pipe our variable to Get-Member, and see what's up.

$redditRSS | Get-Member

It looks like we indeed have an XML object, and its associated methods/properties. 

Let's see what the data looks like!

$redditRSS

The object we have here has a lot of properties that we can dive into. 

For instance, let's say we want the author information for the first element in the array...

$redditRSS[0].author

Under the author property there are more properties that contain the information we're looking for. To access those, we need to drill down to them.

$redditRSS[0].author.name
$redditRSS[0].author.name.uri

This is part of the discovery you'll want to do when you receive an object back after using Invoke-RestMethod. Dig around, check documentation from the website if there is any, and discover all that you can. There is a lot of information returned, and the more you know, the more things you can do!

Let's dig into the content of the first array element.

$redditRSS[0].content

It looks like we want the '#text' property to see the actual returned content.

$redditRSS[0].content.'#text'

The content we received is in HTML. With some parsing we could make it more readable in the console.

Here's one way to clean it up, by stripping out the HTML tags:

$redditRSS[0].content.'#text' | ForEach-Object { $_ -replace '<[^>]+>','' }

With this object ($redditRSS[0]), we can also get the title and URL to the actual post.

$redditRSS[0].title
$redditRSS[0].link.href

The link value was buried in the href property under link.

What can we do with this information? Well... whatever you need/want to! That's the awesome part about PowerShell. If you need it to do something, there's a way to do it! We could setup a script that watches for a certain post with a specific title, and then have it email you or send you a text. 

You can even make a make-shift post explorer, wherein you can check out the posts and comments of a specific subreddit. I have some example code for doing that as the conclusion to this section.

Code for subreddit exploration:

function Invoke-RedditBrowsing { #Begin function Invoke-RedditBrowsing
    [cmdletbinding()]
    Param(
        [Parameter(Mandatory)]
        [String]
        $subReddit
    )

    #Get the RSS feed for the subreddit requested
    $redditRSS = Invoke-RestMethod "http://www.reddit.com/r/$subReddit.rss"

    #This is a hashtable for the text we'll replace in order to make the comments more readable
    $textToReplace = [hashtable]@{

        '<[^>]+>' = ''
        '&quot;'  = '"'
        '&#39;'   = "'"
        '&#32;'   = ' '

    }

    #Make sure there is content before we proceed
    if ($redditRSS) { #Begin if for $redditRSS existence

        #Use the evil Write-Host to display information on what to do next
        Write-Host 'Select a [#] from the options below!' `n -ForegroundColor Black -BackgroundColor Green

        #Set $i to 0 so our loop iterates and displays $i correctly
        $i = 0 

        #ForEach $post in the subReddit's feed...
        ForEach ($post in $redditRSS) {

            #Use evil Write-Host to display the current value of $i and the title, then the author's name and post updated date
            Write-Host "[$i] -> $($post.Title)"
            Write-Host `t"By: [$($post.author.name)], Updated [$($post.updated)]"`n`n

            #Iterate $i by 1
            $i++

        }

        #Write-Host (new line) to make the next line look prettier
        Write-Host `n

        #Try / Catch to make sure we can convert the input to an integer. If not, we error out
        Try {
        
            #Ask for the post selection so we can proceed
            [int]$selection = Read-Host 'Which [#]? (any invalid option quits)' -ErrorAction SilentlyContinue

        }

        Catch {

            #If we can't make it an int... quit!
            Write-Host "Invalid option, quitting!" -ForegroundColor Red -BackgroundColor DarkBlue

            Break

        }
        
        #Switch statement for $selection. This makes sure it is in bounds for the array of posts.
        Switch ($selection) { #Begin Switch for the $selection value

            #if $i is less than or equal to the total number of values in the array $redditRSS...
            {$_ -le ($i - 1)} { #Begin option for valid selection

                #Get the comments RSS feed from the link provided
                $redditComments = Invoke-RestMethod -Uri "$($redditRSS[$_].link.href).rss"
                
                #Write-Host the title we'll be viewing comments for
                Write-Host `n
                Write-Host "Title: [$($redditRSS[$_].Title)]" -ForegroundColor Black -BackgroundColor Green

                #ForEach comment in the comments feed
                ForEach ($comment in $redditComments) {

                    #Null out anything set in $commentText
                    $commentText = $null

                    #Set the comment text as the property which contains the actual comment
                    $commentText = $comment.Content.'#text'

                    #Go through each key in the hashtable we created earlier
                    ForEach ($text in $textToReplace.Keys) {
            
                        #Re-create the $commentText variable while replace the key ($text), with the value ($textToReplace.$text)
                        #For example: it will match '<[^>]+>', then replace it with ''
                        $commentText = $commentText -replace ($text,$textToReplace.$text) 

                    }

                    #Use Write-Host to write out the author/comment text we cleaned up
                    Write-Host `n`n
                    Write-Host "By: [$($comment.author.name)]" -ForegroundColor Black -BackgroundColor Green
                    Write-Host `t"Comment: [$commentText]"

                }

            } #End option for valid selection

            #If the number does not match a value that is valid, quit!
            Default {

                Write-Host "Invalid option, quitting!" -ForegroundColor Red -BackgroundColor DarkBlue
        
                Break

            }

        } #End Switch for the $selection value

    #If there is nothing in $redditRSS... quit!
    } else {

        Write-Host `n"Unable to get RSS feed for [$subReddit]." -ForegroundColor Red -BackgroundColor DarkBlue

    } #End if $redditRSS

} #End function Invoke-RedditBrowsing

Invoke-RedditBrowsing

Now to run it! It will prompt for the subreddit, which can be any subreddit. I will go with PowerShell.

browse1.PNG

The code will then run, and get the list of posts:

I will choose option [0], which is the first post.

There you have it, one example of what you can do with the data returned via Invoke-RestMethod. The primary motivation for doing something like this, at least for me, is that I like to visualize the data. 

Using Write-Host is not suitable for automation, as it will not be seen by anyone. There are better options as well when you want to make specific things verbose, and others specifically for debug / errors. So why use it? Well, when visualizing the data you have available, it really can help certain bits of text stand out. 

With a visual representation of what's available, it can lead to even more discovery and experimenting, which can in turn lead to more script ideas and ways to handle the data later.

Invoke-RestMethod and APIs

Invoke-RestMethod allows us to work with REST APIs with relative ease. When working with APIs, the most important thing is to familiarize yourself with their documentation. I'll link to the documentation for the different APIs I'll be using here.

Example 1: No Authentication

For this example we will use Weather Underground's Autocomplete API. This API returns a list of locations that best match the input it receives. It can be used in tandem with their full API to get the weather information.

Let's try the following:

$city          = 'Chicago'

$lookupResults = Invoke-RestMethod -Uri "http://autocomplete.wunderground.com/aq?query=$city"

$lookupResults

Looks like we have an object we can explore now! 

$lookupResults | Get-Member

It is a custom object with one property (RESULTS). Let's check it out.

$lookupResults

Quite a few results! We can access the first result by using $lookupResults.RESULTS[0].

The 'l' property is something we will actually use later when getting a forecast for Chicago.

Let's wrap this up in a nice little function.

function Get-WeatherCity { #Begin function Get-WeatherCity
    [cmdletbinding()]
    param(
        [parameter(Mandatory=$true)]
        [string]
        $city
    )

    Try {
        
        $lookupResults = Invoke-RestMethod -Uri "http://autocomplete.wunderground.com/aq?query=$city"

    }

    Catch {
        
        $errorMessage = $_.Exception.Message

        Write-Host "Error looking up [$city]: [$errorMessage]"

        Return

    }

    if ($lookupResults.results) {

        Return $lookupResults.Results[0]

    } else {

        Write-Host "Unable to find a result for: [$city]" -ForegroundColor Red -BackgroundColor DarkBlue

    }
        
} #End function Get-WeatherCity

Now we can use (with that function in memory): 

Get-WeatherCity -city Chicago

Example 2: API With a Key

For this example I will use Weather Underground's free API (for developers). To sign up for a key, go here. For the API's documentation, go here.

We'll use the following base code along side this example: 

function Get-WeatherCity { #Begin function Get-WeatherCity
    [cmdletbinding()]
    param(
        [parameter(Mandatory=$true)]
        [string]
        $city
    )

    Try {
        
        $lookupResults = Invoke-RestMethod -Uri "http://autocomplete.wunderground.com/aq?query=$city"

    }

    Catch {
        
        $errorMessage = $_.Exception.Message

        Write-Host "Error looking up [$city]: [$errorMessage]"

        Return

    }

    if ($lookupResults.results) {

        Return $lookupResults.Results[0]

    } else {

        Write-Host "Unable to find a result for: [$city]" -ForegroundColor Red -BackgroundColor DarkBlue

    }
        
} #End function Get-WeatherCity

With that function in memory, let's get a returned object for Chicago.

$cityInfo = Get-WeatherCity Chicago

Now for the setup to use the API to get weather information.

This code is one time code to keep my key secure so somebody reading the script can't see it in plain text. 

To do this I will:

  • Set the API key as a secure string in a variable
  • Create a credential object to store the key
  • Export that credential object via Export-Clixml
$apiKey          = 'EnterYourKeyHere' | ConvertTo-SecureString -AsPlainText -Force
$apiCredential   = New-Object System.Management.Automation.PSCredential('apiKey',$apiKey)
$apiCredential | Export-Clixml .\apiCred.xml

With that one time setup out of the way, you can then use:

$apiKey = (Import-Clixml .\apiCred.xml).GetNetworkCredential().Password

The key will be available to the script (as plain text), but it is stored in the $apiKey variable.

With all the setup done, here's the block of code we will be running:

$apiKey          = (Import-Clixml .\apiCred.xml).GetNetworkCredential().Password
$baseURL         = 'http://api.wunderground.com/api/'
$cityInfo        = Get-WeatherCity Chicago
$fullURL         = $baseURL + $apiKey + '/features/conditions/hourly/forecast/webcams/astronomy/alerts' + "$($cityInfo.l).json"
$weatherForecast = Invoke-RestMethod -Uri $fullURL

With the above code executed, $weatherForecast will now contain a plethora of data for us to work with.

$weatherForecast | Get-Member

This is one rich object! For alerts, you can use: $weatherForecast.alerts.

You can do a lot with the information available. I wrote a script that uses it to email a forecast (just the way we want it) to my significant other and myself. I took a parts of the script, and heavily modified them to fit this example. In the following example we will:

  • Setup the API information needed
  • Set a limit on use during the script's execution to 10
  • Declare 3 cities we want the forecast for
  • Create a PowerShell class (PowerShell version 5 only!)
    • This class will have a method that formats the forecast in HTML
  • Lookup the city information
  • Pass the city information to a function that gathers the API data
  • Create an object using the class
  • Loop through the cities, get the information, update the object we created with the class, and export the HTML forecast after using the object's method to create it

Here's the code:

$apiKey          = (Import-Clixml .\apiCred.xml).GetNetworkCredential().Password
$baseURL         = 'http://api.wunderground.com/api/'
$cityInfo        = Get-WeatherCity Chicago
$fullURL         = $baseURL + $apiKey + '/features/conditions/hourly/forecast/webcams/astronomy/alerts' + "$($cityInfo.l).json"
$weatherForecast = Invoke-RestMethod -Uri $fullURL
$citiesToGet     = ('Chicago','San Francisco','Portland')
$script:apiLimit = 10

Class Weather { #Begin Weather Class

    [pscustomobject]$apiData

    [string[]]formatForecastHTML() { #Begin method formatForecastHTML

        [System.Collections.ArrayList]$alertText    = @()
        [System.Collections.ArrayList]$forecastText = @() 
        $phases                                     = $null
        $todayForecast                              = $this.apiData.forecast.simpleforecast.forecastday
        $city                                       = $this.apiData.current_observation.display_location.full
        $selCam                                     = Get-Random $this.apiData.webcams.count
        $camImg                                     = $this.apiData.webcams[$selCam].CURRENTIMAGEURL
        $camName                                    = $this.apiData.webcams[$selCam].linktext
        $camLink                                    = $this.apiData.webcams[$selCam].link
        $curAlerts                                  = $this.apiData.alerts 
            
        if ($curAlerts) {

        foreach ($alert in $CurAlerts) {
            $typeName   = $null
            $typeName   = Get-WeatherInfo -Weather 'alert' -value $alert.type

            $alertText.Add(@"
<p><b><font color=`"red`">Weather Alert! ($typeName)</font></b></p>
<p>Date: $($alert.date) Expires: $($alert.Expires)</p>
<p>$($alert.Message)</p>
"@) | Out-Null              
                            
        } 

        }    
        
        $phases = @"
<p><b>Sun Information</b></p><p></p>
<p>Sunrise: $($this.apiData.sun_phase.sunrise.hour):$($this.apiData.sun_phase.sunrise.minute)</p>
<p>Sunset: $($this.apiData.sun_phase.sunset.hour):$($this.apiData.sun_phase.sunset.minute)</p>
<p></p>
<p><b>Moon Information</b></p><p></p>
<p>Moonrise: $($this.apiData.moon_phase.moonrise.hour):$($this.apiData.moon_phase.moonrise.minute)</p>
<p>Moonset: $($this.apiData.moon_phase.moonset.hour):$($this.apiData.moon_phase.moonset.minute)</p>
<p>Age: $($this.apiData.moon_phase.ageOfMoon) Days</p>
<p>Phase: $($this.apiData.moon_phase.phaseofMoon)</p>
<p>Illumination: $($this.apiData.moon_phase.percentIlluminated)%</p>
"@

        foreach ($day in $todayForecast) {
   
            $dayImg          = $day.icon_url
            $dayMonth        = $day.date.monthname
            $dayDay          = $day.date.day
            $dayName         = $day.date.weekday
            $dayHigh         = $day.high.fahrenheit  
            $dayLow          = $day.low.fahrenheit
            $maxWind         = $day.maxwind.mph
            $aveWind         = $day.avewind.mph
            $aveHum          = $day.avehumidity
            $conditions      = $day.conditions
            [int]$dayPrecip  = $day.pop
                
            $popText = Get-WeatherInfo -Weather 'preciptext' -value $dayPrecip
            
            $forecastText.Add(@"
<p></p>
<p></p>
<p><b>$dayName, $dayMonth $dayDay</b></p>
<p><img src=`"$dayImg`">$conditions</p>
<p>Chance of precipitation: $dayPrecip% / $popText</p>
<p>High: $dayHigh`F Low: $dayLow`F</p>
<p>Ave Winds: $aveWind`mph Max Winds: $maxWind`mph</p>
<p>Humidity: $aveHum%</p>
"@) | Out-Null                           

        }

        $body = @"
<p></p>
<p>Here is your 4 day forecast!</p>
<p>Random webcam shot from: <a href=`"$camLink`">$camName</a></p>
<p><img src=`"$camImg`"></p>
$($phases       | Out-String)
$($alertText    | Out-String)
$($forecastText | Out-String)
"@

        Return $body 

    } #End method formatForecastHTML

    [string[]]formatHourlyForecastHTML() { #Begin Class format4DayForecast

        [System.Collections.ArrayList]$alertText    = @()
        [System.Collections.ArrayList]$forecastText = @() 
        $phases                                     = $null
        $todayForecast                              = $this.apiData.forecast.simpleforecast.forecastday
        $city                                       = $this.apiData.current_observation.display_location.full
        $selCam                                     = Get-Random $this.apiData.webcams.count
        $camImg                                     = $this.apiData.webcams[$selCam].CURRENTIMAGEURL
        $camName                                    = $this.apiData.webcams[$selCam].linktext
        $camLink                                    = $this.apiData.webcams[$selCam].link
        $curAlerts                                  = $this.apiData.alerts 
        $hourlyForecast                             = $this.apiData.hourly_forecast
            
        if ($curAlerts) {

            foreach ($alert in $CurAlerts) {
            
                $typeName   = $null
                $typeName   = Get-WeatherInfo -Weather 'alert' -value $alert.type
            
                $alertText.Add(
@"
<p><b><font color=`"red`">Weather Alert! ($typeName)</font></b></p>
<p>Date: $($alert.date) Expires: $($alert.Expires)</p>
<p>$($alert.Message)</p>
"@) | Out-Null              
                            
            } 

        }  
         
        $phases = @"
<p><b>Sun Information</b></p><p></p>
<p>Sunrise: $($this.apiData.sun_phase.sunrise.hour):$($this.apiData.sun_phase.sunrise.minute)</p>
<p>Sunset: $($this.apiData.sun_phase.sunset.hour):$($this.apiData.sun_phase.sunset.minute)</p>
<p></p>
<p><b>Moon Information</b></p><p></p>
<p>Moonrise: $($this.apiData.moon_phase.moonrise.hour):$($this.apiData.moon_phase.moonrise.minute)</p>
<p>Moonset: $($this.apiData.moon_phase.moonset.hour):$($this.apiData.moon_phase.moonset.minute)</p>
<p>Age: $($this.apiData.moon_phase.ageOfMoon) Days</p>
<p>Phase: $($this.apiData.moon_phase.phaseofMoon)</p>
<p>Illumination: $($this.apiData.moon_phase.percentIlluminated)%</p>

"@            
        foreach ($hour in $hourlyForecast) {

            $prettyTime       = $hour.fcttime.pretty
            $hourTemp         = $hour.temp.english  
            $hourImg          = $hour.icon_url
            $hourChill        = $hour.windchill.english
            if ($hourChill -eq -9999) {
                
                $hourChill = 'N/A'
                    
            } else {
                    
                $hourChill = $hourChill + 'F'
                
            }

            $hourWind         = $hour.wspd.english
            $windDir          = $hour.wdir.dir
            $hourUV           = $hour.uvi
            $dewPoint         = $hour.dewpoint.english
            $hourFeels        = $hour.feelslike.english
            $hourHum          = $hour.humidity
            $conditions       = $hour.condition
            [int]$hourPrecip  = $hour.pop
                
            $popText = Get-WeatherInfo -Weather 'preciptext' -value $hourPrecip
            $forecastText.Add(
@"
<p></p>
<p></p>
<p><b>$prettyTime</b></p>
<p><img src=`"$hourImg`">$conditions</p>
<p>Chance of precipitation: $hourPrecip% / $popText</p>
<p>Current Temp: $hourTemp`F Wind Chill: $hourChill Feels Like: $hourFeels`F</p>
<p>Dew Point: $dewPoint</p>
<p>Wind Speed: $hourWind`mph Direction: $windDir</p>
<p>Humidity: $hourHum%</p>
<p>UV Index: $hourUV     
"@) | Out-Null                

        }

        $body = 
@"
<p></p>
<p>Here is your hourly forecast!</p>
<p>Random webcam shot from: <a href=`"$camLink`">$camName</a></p>
<p><img src=`"$camImg`"></p>
<p>$city Radar:</p>
<p><img src=`"$($this.radarURL)`"></p>
$($phases       | Out-String)
$($alertText    | Out-String)
$($forecastText | Out-String)
"@
        return $body 

    } #End method formatHourlyForecastHTML

    Weather([String]$apiURL, [String]$radarURL, [PSCustomObject]$apiData) {
        
        $this.radarURL = $radarURL
        $this.apiData  = $apiData

    }

    Weather() {}

} #End class Weather

function Get-WeatherCity { #Begin function Get-WeatherCity
    [cmdletbinding()]
    param(
        [parameter(Mandatory=$true)]
        [string]
        $city
    )

    Try {
        
        $lookupResults = Invoke-RestMethod -Uri "http://autocomplete.wunderground.com/aq?query=$city"

    }

    Catch {
        
        $errorMessage = $_.Exception.Message

        Write-Host "Error looking up [$city]: [$errorMessage]"

        Return

    }

    if ($lookupResults.results) {

        Return $lookupResults.Results[0]

    } else {

        Write-Host "Unable to find a result for: [$city]" -ForegroundColor Red -BackgroundColor DarkBlue

    }
        
} #End function Get-WeatherCity

function Get-WeatherAPIData { #Begin function Get-WeatherAPIData
    [cmdletbinding()]
    param(
        [parameter(Mandatory)]
        [PSCustomObject]
        $city
    )
    
    [System.Collections.ArrayList]$weatherCity = @()
    $weatherProperty                           = $null
    $weatherO                                  = $null

    $fullURL  = $baseURL + $apiKey + '/features/conditions/hourly/forecast/webcams/astronomy/alerts' + "$($city.l).json"
    $radarURL = "http://api.wunderground.com/api/$apiKey/animatedradar/animatedsatellite" + "$($city.l).gif?num=6&delay=50&interval=30"

    Try {

        if ($script:apiCount -le $apiLimit) {

            Write-Host "Attempting to get API information for: [$($city.Name)]."  
                  
            $weatherForecast = Invoke-RestMethod -Uri $fullURL 
                
            $script:apiCount++

            $weatherForecast | Export-Clixml ("$outputDir\$($city.Name)APIData{0:MM-dd-yy_HHmm}.clixml" -f (Get-Date)) 

            Write-Host "The API has been used [$script:apiCount] times, against the limit of [$apiLimit]"

        } else {

            Write-Host "The API limit of [$apiLimit] has been reached!" -ForegroundColor Red -BackgroundColor DarkBlue

            Break

        }

    }

    Catch {

        $errorMessager = $_.Exception.Message 

        Write-Host "Unabled to get API data from Weather Underground: [$($_.Exception.Message)]." -ForegroundColor Red -BackgroundColor DarkBlue

    }

    if ($weatherForecast.forecast) {

        Write-Host "API information found for: [$($city.Name)]"

        $weatherO = [PSCustomObject]@{

            APIData  = $weatherForecast
            RadarURL = $radarURL 

        }

        $weatherCity.Add($weatherO) | Out-Null

        Return $weatherCity

    } else {

        Write-Host "API information not found for: [$($city.Name)]" -ForegroundColor Red -BackgroundColor DarkBlue

    }
  
} #End function Get-WeatherAPIData

function Get-WeatherInfo { #Begin function Get-WeatherInfo
    [cmdletbinding()]
    param(
        [string]
        $weather,
        $value       
    )
    
    Switch ($weather) {
        
        {$_ -eq 'preciptext'} {
        
            Switch ($value) {
                
                {$_ -lt 20} {
                    
                    $popText = 'No mention'
                    
                }
                
                {$_ -eq 20} {
                    
                    $popText = 'Slight Chance'
                    
                }
                
                {($_ -lt 50 -and $_ -gt 20)} {
                    
                    $popText = 'Chance'
                    
                }
                
                {$_ -eq 50} {
                    
                    $popText = 'Good chance'
                    
                }
                
                {($_ -lt 70 -and $_ -gt 50)} {
                    
                    $popText = 'Likely'
                    
                }
                
                {$_ -ge 70} {
                    
                    $popText = 'Extremely likely'
                    
                }
                
            }
            
            Return $popText
            
        }   
        
        {$_ -eq 'alert'}    {
            
            Switch ($value) {
                
                'HEA' {$typeName = 'Heat Advisory'}
                'TOR' {$typeName = 'Tornado Warning'}
                'TOW' {$typeName = 'Tornado Watch'}
                'WRN' {$typeName = 'Severe Thunderstorm Warning'}
                'SEW' {$typeName = 'Severe Thunderstorm Watch'}
                'WIN' {$typeName = 'Winter Weather Advisory'}
                'FLO' {$typeName = 'Flood Warning'}
                'WAT' {$typeName = 'Flood Watch / Statement'}
                'WND' {$typeName = 'High Wind Advisory'}
                'SVR' {$typeName = 'Severe Weather Statement'}
                'HEA' {$typeName = 'Heat Advisory'}
                'FOG' {$typeName = 'Dense Fog Advisory'}
                'SPE' {$typeName = 'Special Weather Statement'}
                'FIR' {$typeName = 'Fire Weather Advisory'}
                'VOL' {$typeName = 'Volcanic Activity Statement'}
                'HWW' {$typeName = 'High Wind Warning'}
                'REC' {$typeName = 'Record Set'}
                'REP' {$typeName = 'Public Reports'}
                'PUB' {$typeName = 'Public Information Statement'}
                    
            }
                
            Return $typeName
    
        }    
    }
    
} #End function Get-WeatherInfo

$weatherObject = [Weather]::New()

Write-Host `n`n

ForEach ($cityName in $citiesToGet) {

    $findCity    = Get-WeatherCity -city $cityName
    $weatherCity = Get-WeatherAPIData -city $findCity -ErrorAction Stop    

    if ($weatherCity) {

        $weatherObject.apiData  = $null
        $weatherObject.apiData  = $weatherCity.APIData

        $foreCastFile = $null
        $foreCastFile = ".\$cityName.html"

        Write-Host `n"Exporting HTML formatted forecast to [$foreCastFile]."`n

        $weatherObject.formatHourlyForecastHTML() | Out-File ".\$cityName.html"

    } else {

        Write-Host "No data in [`$weatherCity] variable, halting execution on [$($userEmail.FirstName) $($userEmail.LastName)]." -ForegroundColor Red -BackgroundColor DarkBlue
                
        Break

    }

}

Write-Host `n`n

There should be 3 files in the folder now.

There they are. Let's take a look at Chicago's forecast.

You can do anything you put your mind to with the data received.

Homework

  • Explore the class created here to see how the information in the HTML generated forecast is created.
    • A lot of this information is transferable to functions and other methods of creating the HTML formatted forecast if you do not have PowerShell version 5.
  • See if a web service you use often has an API, read up on the documentation, and see if there's something you can automate doing with PowerShell!

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to top]

PowerShell: Getting Started - Utilizing the Web: Part 1 (Invoke-WebRequest)

PowerShell: Getting Started - Utilizing the Web: Part 1 (Invoke-WebRequest)

Getting Started - Utilizing the Web: Part 1 (Invoke-WebRequest)

Welcome to my Getting Started with Windows PowerShell series!

Why Work With the Web in PowerShell?

PowerShell can do quite a bit with web sites, and web services. 

Some of what we can do includes:

  • Checking if a website is up
  • Downloading files
  • Logging in to webpages
  • Parsing the content we receive
  • Utilizing REST methods

Learning how to use web services with PowerShell can expand your realm of possibility when scripting.

Using Invoke-WebRequest

Invoke-WebRequest is a command that allows us to retrieve content from web pages. The methods supported when using Invoke-WebRequest are:

  • Trace
  • Put
  • Post
  • Options
  • Patch
  • Merge
  • Delete
  • Default
  • Head
  • Get (Default)

Let's take a look at some different ways to utilize Invoke-WebRequest

Downloading a File

Let's download a file! In this particular example, we will download an addon for World of Warcraft.

The page we'll be looking at is http://www.tukui.org/dl.php.

In particular, let's get the ElvUI download.

PowerShell setup:

$downloadURL     = 'http://www.tukui.org/dl.php'
$downloadRequest = Invoke-WebRequest -Uri $downloadURL

Here I set the $downloadURL variable to store the main page we want to go to, and then use $downloadRequest to store the results of Invoke-WebRequest (using the $downloadURL as the URI).

Let's take a look at what's in $downloadRequest.

We'll go over some of the other properties later, and focus on Links for now. The Links property returns all the links found on the web site. $downloadRequest.Links

The list continues, but you get the idea. This is one of my favorite things about the Invoke-WebRequest command. The way it returns links is very easy to parse through.

Let's hunt for the ElvUI download link. To do that we'll pipe $downloadRequest.Links to Where-Object, and look for links that contain a phrase like Elv and Download.

$downloadRequest.Links | Where-Object {$_ -like '*elv*' -and $_ -like '*download*'}

Got it! Now let's store that href property in a variable.

$elvLink         = ($downloadRequest.Links | Where-Object {$_ -like '*elv*' -and $_ -like '*download*'}).href

That variable will now contain the download link.

Next we'll use Invoke-WebRequest again to download the file. There are two ways we can get the file:

  • Using Invoke-WebRequest to store the results in a variable,  and then write all the bytes to a file using the Contents property (which is a byte array).
  • Using Invoke-WebRequest with the -OutFile parameter set as the full path of the download. With this option we'll want to use -PassThru so we can still get the results from Invoke-WebRequest (otherwise success = empty result/no object returned).

Using the Contents property and writing the bytes out

$fileName        = $elvLink.Substring($elvLink.LastIndexOf('/')+1)
$downloadRequest = Invoke-WebRequest -Uri $elvLink 
$fileContents    = $downloadRequest.Content

The above code takes the link we stored, and gets the file name from it using LastIndexOf, and SubString.

It then stores the download request results in $downloadRequest

Finally, we get the contents (which is a byte array, if all went well), and store that in $fileContents.

The last thing we'll need to do is write the bytes out. To do that we'll use [io.file]WriteAllBytes(path,contents) to write the byte array to a file.

[io.file]::WriteAllBytes("c:\download\$fileName",$fileContents)

Let's run the code now, and see what happens!

Now we should have a file in "C:\download\"...

There it is! 

The $downloadRequest variable stores the results from the request, which you can use to validate accordingly. 

Using Invoke-WebRequest with -OutFile

Another way to download the file would be to use the -OutFile parameter with Invoke-WebRequest. We'll want to set the filename first:

$fileName        = $elvLink.Substring($elvLink.LastIndexOf('/')+1)

This is the same code from the previous example, and it uses LastIndexOf, and SubString.

Here's the code to download the file:

$downloadRequest  =  Invoke-WebRequest -Uri $elvLink -OutFile "C:\download\$fileName" -PassThru

Note that we used -PassThru as well. That is so we can still see the results of the request in the variable $downloadRequest. Otherwise a successful result would return no object, and your variable would be empty.

Let's see if that worked!

It did, and it was a bit easier than the previous example.

Downloading Files With a Redirect

Let's take a look at downloading files from sites that have a redirect. For this example I will use downloading WinPython from https://sourceforge.net/projects/winpython/files/latest/download?source=frontpage&position=4. Note: Invoke-WebRequest is typically great at following redirected links. This example is here to show you how to retrieve redirected links, as well as a cleaner way to get the file.

Here is what a typical page that has a redirected download looks like:

Invoke-WebRequest includes a parameter that will force the maximum number of times it will accept redirection (-MaximumRedirection). We'll set that to 0, which will error it out, and use -ErrorAction SilentlyContinue to allow the error to be ignored. I will also use the parameter -UserAgent to send along the user agent string for FireFox. If we do not do this, the download will not work on this website.

Here's the initial code:

I set the URL we want to download from and store it in $downloadURL

$downloadURL     = 'https://sourceforge.net/projects/winpython/files/latest/download?source=frontpage&position=4'

Then I store the result of our Invoke-WebRequest command in $downloadRequest.

$downloadRequest = Invoke-WebRequest -Uri $downloadURL -MaximumRedirection 0 -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox -ErrorAction SilentlyContinue

Let's take a closer look at the Invoke-WebRequest line.

  • -MaximumRedirection 0
    • This ensures we do not get automatically redirected. Setting it to 0 forces no re-direction, and allows us to manually collect the redirection data.
  • -ErrorAction SilentlyContinue
    • This tells PowerShell to ignore the redirection error message. The downside to this is that it will be hard to capture any other errors. This is the only way I was able to find that keeps information in the variable $downloadRequest.
  • -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox
    • This sends the user agent string for FireFox along in the request. We can see what [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox resolves to by simply typing(or pasting) it in the console.

Now we have some helpful information in our $downloadRequest variable, assuming all went well.

$downloadRequest.StatusDescription should be "Found".

Good! The redirect link is stored in the header information, accessible via $downloadRequest.Headers.Location.

I did some digging in the Content property, and found the string that matches the file name. I then added some code for the $fileName variable that looks for a string that matches the file name, and selects the matched value.

$fileName        = (Select-String -Pattern 'WinPython-.+exe' -InputObject $downloadRequest.Content -AllMatches).Matches.Value

Now that we have this information, we're ready to continue! I used a couple Switch statements to add some logic, in case the responses aren't what we expected.

Here's the full code for this example:

$downloadURL     = 'https://sourceforge.net/projects/winpython/files/latest/download?source=frontpage&position=4'
$downloadRequest = Invoke-WebRequest -Uri $downloadURL -MaximumRedirection 0 -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox -ErrorAction SilentlyContinue
$fileName        = (Select-String -Pattern 'WinPython-.+exe' -InputObject $downloadRequest.Content -AllMatches).Matches.Value

Switch ($downloadRequest.StatusDescription) {

    'Found' {
        
        Write-Host "Status Description is [Found], downloading from redirect URL [$($downloadRequest.Headers.Location)]."`n
        $downloadRequest = Invoke-WebRequest -Uri $downloadRequest.Headers.Location -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox 

    }

    Default {

        Write-Host "Status Descrption is not [Found], received: [$($downloadRequest.StatusDescription)]. Perhaps something went wrong?" -ForegroundColor Red -BackgroundColor DarkBlue

    }

}

Switch ($downloadRequest.BaseResponse.ContentType) {

    'application/octet-stream' {

        Write-Host 'Application content type found!'`n
        Write-Host "Writing [$fileName] to [C:\download]"`n

        [io.file]::WriteAllBytes("c:\download\$fileName",$downloadRequest.Content)

    }

    Default {

        Write-Host "Content type is not an application! Not extracting file." -ForegroundColor Red -BackgroundColor DarkBlue

    }

}

The first Switch statement ensures that the StatusDescription is "Found", then sets $downloadRequest as the result of the Invoke-WebRequest command that now points to the redirect URL. If the StatusDescription is not found, you'll see a message stating that something went wrong.

We then use a Switch statement that ensures our downloaded content (in $downloadRequest) has the Content Type of "application/octet-stream". If it is, we write the file out using [io.file]WriteAllBytes(path,contents).

Let's run the code, and then look in "C:\download\" to verify the results!

While it downloads, this progress indicator is displayed (Sometimes it will not match the actual progress):

Looks like everything worked! One last place to check.

We got it. All 277MB downloaded and written to the appropriate location.

Parsing Content

Using Invoke-WebRequest, the content of the request is returned to us in the object. There are many ways to go through the data. In this example I will demonstrate gathering the titles and their associated links from the PowerShell subreddit.

Here's the setup:

$parseURL    = 'http://www.reddit.com/r/powershell'
$webRequest  = Invoke-WebRequest -Uri $parseURL

Now let's take a look at the $webRequest variable.

  • RawContent
    • This is the content returned as it was received, including header data. 
  • Forms
    • This property contains any discovered forms. We'll go over this portion in more detail when we log in to a website.
  • Headers
    • This property contains just the returned header information.
  • Images
    • This property contains any images that were able to be discovered.
  • InputFields
    • This property returns discovered input fields on the website.
  • Links
    • This returns the links found on the website, in an easy format to iterate through.
  • ParsedHTML
    • This property allows you to access the DOM of the web site. DOM is short for Document Object Model. Think of DOM as a structured representation of the data on the website.

As always, if you want to see what other properties were returned, and any methods available, pipe $webRequest to Get-Member.

As you can see there are a few more properties that exist, but we'll be focusing on the ones described above in this article.

Now to get the title text from the current posts at http://www.reddit.com/r/powershell.

The fastest way to narrow it down, is to launch a browser and take a look at the DOM explorer. In Edge I used [F12] to launch the developer tools, and then used the [Select Element] option in the [DOM Explorer] tab. I then selected one of the posts to see what it looked like. 

It looks like the link is under a class named title, and the tag <p>. 

Let's use the ParsedHTML property to access the DOM, and look for all instances of <p> where the class is title.

$webRequest.ParsedHTML.getElementsByTagName('p') | Where-Object {$_.ClassName -eq 'title'}

The results should look similar to this (quite a lot of text will scroll by):

To verify if this is just the title information, let's pipe the above command to | Select-Object -ExpandProperty OuterText

$webRequest.ParsedHTML.getElementsByTagName('p') | Where-Object {$_.ClassName -eq 'title'} | Select-Object -ExpandProperty OuterText

Awesome, looks like what we want to see!

Let's store all those titles (minus the (text)) at the end, in $titles.

The biggest problem I encountered was just getting the title names (minus the text after at the very end such as: (self.PowerShell)), while also not omitting results that had (text) in other places. Here is the solution I came up with to store all the post titles in the variable $titles.

$titles      = $webRequest.ParsedHTML.getElementsByTagName('p') | 
               Where-Object {$_.ClassName -eq 'title'}          |                    
               ForEach-Object {
               
                    $splitTitle = $null
                    $splitCount = $null
                    $fixedTitle = $null

                    $splitTitle = $_.OuterText.Split(' ')
                    $splitCount = $splitTitle.Count

                    $splitTitle[($splitCount - 1)] = $null

                    $fixedTitle = ($splitTitle -join ' ').Trim()

                    Return $fixedTitle

               }

In the above command, I piped our title results to ForEach-Object, and then used some string manipulation to split the title into an array, null out the last entry in the array, join the array back, and finally trim it so there is no extra white space.

Now let's take a look at our $titles variable.

Perfect! The next step is matching the titles up with the property Links in our $webRequest variable. Remember that $webRequest.Links contains all the links on the web site.

After some digging, I found that the link property of outerText matches the titles in our $titles variable. Now we can iterate through all the titles in $titles, find the links that match, and create a custom object to store an index, title, and link.

We will need to do some more string manipulation to get the link out of the outerHTML property. It's all doable, though! 

Finally, we'll store the custom object in an array of custom objects. 

Here is the full code (with comments to explain what is happening):

#Ensure i is set to 0 so it iterates properly in the loop below
$i = 0
#Create the .Net ArrayList object to store our custom objects
[System.Collections.ArrayList]$prettyLinks = @()
#Iterate through all the titles
ForEach ($title in $titles) {

    #Search through the Links property of $webRequest and take action where the outerText equals the title
    #For each of the found requests (each link that matches)...
    $webRequest.Links  | Where-Object {$_.outerText -eq $title} | ForEach-Object {
    
    #Null out variables used in this loop
    $linkObject = $null
    $linkURL    = $null
    $titleText  = $null

    #Set the link URL as $link using the outerHTML property of the Link
    $link       = $_.outerHTML
    #Set the title text as $titleText using the outerText property of the Link
    $titleText  = $_.outerText

    #Split the link where there are spaces, and keep the part that matches href
    $link       = $link.Split(' ') | Where-Object {$_ -match '^href'}
    #Manipulate the link to get just the url, and trim and extra quotes
    $link       = $link.SubString($link.IndexOf('"')).Trim('"')

    #If the link is a link to an external source (starts with http:// or https://), then it is a full link, set $linkURL to $link
    if ($link -match '^https?://.+') {

        $linkURL = $link
    
    #Else it is a link to this subreddit as a self post, $linkURL prepends 'http://www.reddit.com' to the $link (which is /r/powershell/...)
    } else {

    
        $linkURL = ('http://www.reddit.com' + $link)

    }
    
    #Create a custom object to store the index ($i), the title, and link
    $linkObject = [PSCustomObject]@{

        Index = $i
        Title = $titleText
        Link  = $linkURL

    }

    #Add the object to our object array
    $prettyLinks.Add($linkObject) | Out-Null

    #Add 1 to $i
    $i++    
   
    }  

}

Let's run the code, and then take a look at our $prettyLinks variable.

That looks good, and the object is at our disposal for whatever we'd like to do with the information.

For an example on how the code can be used, check this out!

$browsing = $true

While ($browsing) {

    $selection = $null

    Write-Host "Select a [#] from the titles below!"`n -ForegroundColor Black -BackgroundColor Green

    ForEach ($pretty in $prettyLinks) {

        Write-Host "[$($pretty.Index)] $($pretty.Title)"`n

    }

    Try {
        
        [int]$selection = Read-Host 'Which [#]? "q" quits'

        if ($selection -le ($prettyLinks.Count -1)) {

            Start-Process $prettyLinks[$selection].Link 

        } else {

            $browsing = $false

            Write-Host '"q" or invalid option selected, browsing aborted!' -ForegroundColor Red -BackgroundColor DarkBlue
            
        }

    }

    Catch {

        $browsing = $false

        Write-Host '"q" or invalid option selected, browsing aborted!' -ForegroundColor Red -BackgroundColor DarkBlue        

    }

}

The above code creates a loop until the user inputs "q", or an invalid option. It will list out all of the titles, and then ask you for the number of the one you want to look at. Once you input the number, it will launch your default browser to the title's associated link.

This is but one of many examples of how the data can be used. Check out these screenshots to see the code in action.

Let's select 17.

Here's what happens if you put "q".

Working With Forms

Invoke-WebRequest can also work with form data. We can get the forms from the current request, manipulate the data, and then submit them.

Let's take a look at a simple one, searching Reddit.

$webRequest = Invoke-WebRequest 'http://www.reddit.com'

Let's take a look at $webRequest.Forms:

Now that we know that the search form is the first array value, let's declare $searchForm as $webRequest.Forms[0].

$searchForm = $webRequest.Forms[0]

Now $searchForm will contain the form we care about.

Here are the properties we see at a glance:

  • Method
    • This is the method we'll use when sending the request with the form data.
  • Action
    • This is the URL used with the request. Sometimes it is the full URL, other times it is part of a URL that we need to concatenate together with the main URL.
  • Fields
    • This is a hash table that contains the fields to be submitted in the request.

Here are the values in $searchForm.Fields

Let's set the value of "q" to what we'd like to search for. I will set it to: "PowerShell".

$searchForm.Fields.q = 'PowerShell'

It's always good to verify things are as they should be. 

$searchForm.Fields

That looks good! Now to format our next request, and search Reddit!

$searchReddit = Invoke-WebRequest -Uri $searchForm.Action -Method $searchform.Method -Body $searchForm.Fields

In this request, the following parameters are set:

  • -Uri
    • We use $searchForm.Action  for this as that contains the full URL we need to use.
  • -Method
    • We use $searchForm.Method for this. Technically it would default to using Get, but that is not the case for all forms. It is good to use what the form states to use.
  • -Body
    • We use $searchForm.Fields for this, and the body is submitted as the hash table's key/value pairs. In this case that is "q" = "PowerShell". 

Now that we have the results in $searchReddit, we can validate the data by taking a look at the links.

$searchReddit.Links | Where-Object {$_.Class -eq 'search-title may-blank'} | Select-Object InnerText,Href

Now that we've validated it worked, you could also parse the contents to get what you want out of it!

Full code for this example:

$webRequest          = Invoke-WebRequest 'http://www.reddit.com'
$searchForm          = $webRequest.Forms[0]
$searchForm.Fields.q = 'PowerShell'
$searchReddit        = Invoke-WebRequest -Uri $searchForm.Action -Method $searchform.Method -Body $searchForm.Fields

$searchReddit.Links | Where-Object {$_.Class -eq 'search-title may-blank'} | Select-Object InnerText,Href

Logging In To Web Sites

We can also use Invoke-WebRequest to log in to web sites. To do this we'll need to be sure to do the following:

  • Set the userAgent to Firefox.
    • This may not be required, but it is generally safe to do.
  • Use the sessionVariable parameter to create a variable.
    • This will be used to maintain the session, and store cookies.
  • Populate the correct form with login details.
    • We'll store the credentials in $credential via Get-Credential.

We'll start by storing my credentials for Reddit in $credential, setting $uaString to the FireFox user agent string, and finally using Invoke-WebRequest to initiate our session.

$credential = Get-Credential
$uaString   = [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox
$webRequest = Invoke-WebRequest -Uri 'www.reddit.com' -SessionVariable webSession -UserAgent $uaString

!!NOTE!! When setting the parameter -SessionVariable, do not include the "$" in the variable name.

$webRequest.Forms contains all the forms.

The Id of the form we need is "login_login-main". Knowing this, we can use the following line to get just the form we need:

$loginForm               = $webRequest.Forms | Where-Object {$_.Id -eq 'login_login-main'}

Now to check $loginForm.Fields to be sure it is what we need, and to see what the properties we need to set are.

Let's set the fields "user" and "passwd" using the $credential variable we created earlier.

$loginForm.Fields.user   = $credential.UserName
$loginForm.Fields.passwd = $credential.GetNetworkCredential().Password

!!NOTE!! The $loginForms.Fields.passwd property will store the password as plain text.

Alright! Now that our setup is complete, we can use the following command to attempt to log in:

$webRequest = Invoke-WebRequest -Uri $loginForm.Action -Method $loginForm.Method -Body $loginForm.Fields -WebSession $webSession -UserAgent $uaString

This request contains the following information:

  • -Uri $loginForm.Action
    • This uses the URL provided in the property Action from the login form.
  • -Method $loginForm.Method
    • This uses the Method provided in the property Method from the login form.
  • -Body $loginForm.Fields
    • This sends along the hash table (which includes our username and password) along with the request.
  • -WebSession $webSession
    • This tells Invoke-WebRequest to use the SessionVariable we created for cookie storage. We use $webSession this time, as the initial request was creating the variable, and we are utilizing it now.
  • -UserAgent $uaString
    • This sends along the FireFox user agent string we set earlier.

We can now use the following code to verify if we've succeeded in logging in:

if ($webRequest.Links | Where-Object {$_ -like ('*' + $credential.UserName + '*')}) {

    Write-Host "Login verified!"

} else {

    Write-Host 'Login unsuccessful!' -ForegroundColor Red -BackgroundColor DarkBlue

}

It worked! This verification check works by doing a wildcard search for the username that is stored in the credential object $credential in any of the web site's links.

Now that you have an authenticated session, you can browse/use Reddit with it by using the parameter -WebSession, and the value $webSession.

Full code for this example:

#Store the credentials you are going to use!
#Note: if you want to securely store your credentials, you can use $credential | Export-CliXML .\credential.xml
#Then you can import it by using $credential = Import-CliXML .\credential.xml
$credential = Get-Credential
#Set $uaString as the FireFox user agent string
$uaString   = [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox
#Store the initial WebRequest data in $webRequest, create the session variable $webSession
$webRequest = Invoke-WebRequest -Uri 'www.reddit.com' -SessionVariable webSession -UserAgent $uaString

#Gather and set login form details
$loginForm               = $webRequest.Forms | Where-Object {$_.Id -eq 'login_login-main'}
$loginForm.Fields.user   = $credential.UserName
#NOTE: This will store the password in the hash table as plain text
$loginForm.Fields.passwd = $credential.GetNetworkCredential().Password

#Attempt to log in using the Web Session $webSession, with the information provided in $loginForm
$webRequest = Invoke-WebRequest -Uri $loginForm.Action -Method $loginForm.Method -Body $loginForm.Fields -WebSession $webSession -UserAgent $uaString

#Validate if the login succeeded, then take action accordingly.
if ($webRequest.Links | Where-Object {$_ -like ('*' + $credential.UserName + '*')}) {

    Write-Host "Login verified!"

} else {

    Write-Host 'Login unsuccessful!' -ForegroundColor Red -BackgroundColor DarkBlue

}

Homework

  • What error checking could we have applied to some of these examples?
  • Think of things you could automate when using Invoke-WebRequest that you use a browser for every day.
  • What better way could we have validated the data in the final example in the parsing data section?
  • How could we prompt the user for input as to what to search Reddit for?

Keep an eye out for Parts 2 and 3, coming in the next couple weeks!

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to top]

PowerShell: Getting Started - Importing, Using, and Exporting Data

PowerShell: Getting Started - Importing, Using, and Exporting Data

Getting Started - Importing, Using, and Exporting Data

Importing Data Into PowerShell

PowerShell offers a variety of ways to import data. I will be going over just a few of the ways in this article, but a lot of the concepts stay the same no matter which way you need to use. Remember that at the end of the day, the best way is the one that gets the job done for you!

The general guideline I follow is:

  • Import the data
  • Validate the data
  • Iterate through the data and take actions
  • Export data when needed

Text Files

While this method isn't as structured as others, it can really be handy in a pinch. You can copy and paste a list of users, computers, or anything else you want to iterate through, and then save it as a text file. Once you have a file, you can use PowerShell to import the contents.

For this example, I will import the contents of Cities.txt, seen here:

I'll use Get-Content to store the contents of the file in the variable $Cities.

$Cities = Get-Content .\Cities.txt

Let's take a look at the $Cities variable.

The contents have been successfully imported. We can see what type of object we're working with by piping $Cities to Get-Member.

$Cities | Get-Member

Looks like we're working with an array of strings. We can see this by using the Count property, as well as taking a look at the first value in the array, which would be reflected as $Cities[0].

CSV Files

PowerShell works nicely with CSV files. This method of importing data is a bit more structured, and overall is very clean. The headers of the CSV file will be imported as properties, and we'll take a look at that after we import the contents. 

For this example I will import a CSV file that contains a list of users that HR wants us to create in our environment. I will use the command Import-CSV, and store the contents in the variable $Users.

Here is what the CSV file looks like:

Let's go ahead and import the CSV now.

$Users = Import-CSV .\ADUsers.csv

Now let's see what $Users contains.

Awesome! We even have some properties now.  Let's see what we have when we pipe $Users to Get-Member.

Looks like CSVs are imported into a custom object, which we can then use as needed!

We can use the Count property to get the number of users imported, as well as take a look at the first value ($Users[0]) of the array. 

CliXML Files

CliXML files in PowerShell are a special type of XML file that represent object(s) that have been exported. This is extremely handy when you need to export an object that you'll later want to import into PowerShell.  You are also able to export information as CliXML that contains credentials, securely. 

Using CliXML is the best way to export/import objects that you are going to use directly in PowerShell.

For this example, I will import a CliXML file I exported, which contains the results of Get-Process.

Here is the file:

Another benefit of CliXML is it maintains hierarchies. Let's import this content into the variable $Processes, via Import-Clixml.

$Processes = Import-Clixml .\Processes.xml

Success! We can see the objects stored in the $Processes variable.

Let's pipe $Processes to Get-Member and see what we have.

$Processes | Get-Member

Our object TypeName will have the saved object type appended to "Deserialized", and in our case that is "Deserialized.System.Diagnostics.Process".

We can use the Count property to get the number of objects in $Processes, as well as look at the first value by using $Processes[0].

Validation

After we import the information into PowerShell, validating it is the next important step. I went over some basic examples in my article on error handling. In this article we will be going further into how to validate data.

Text Files

There are a few different ways to validate information in text files. Using our example of importing cities from above, here is what our $Cities variable contains:

What problems can you spot with this? I see two! 

  • $3477|3 is not a city
  • There seem to be some blank spaces at the end (Carriage returns)

Blank spaces can do some not-so-awesome things in scripts.

The easy fix for this script, since we're looking for city names, is to use the following regular expression, with the -match comparison operator: [a-z]. If you're not familiar with regular expressions, they can seem confusing at first. This basically says match any characters a-z. 

Visit http://regexone.com/ to learn more about Regex, I've found it to be a great resource.

Let's re-declare our variable, using itself, Where-Object, and -match

$Cities = $Cities | Where-Object {$_ -match '[a-z]'}

Now let's see the contents of $Cities.

That looks much better!

CSV/CliXML

Validating data from importing CSVs or CliXML files is a similar process. Since we'll have properties to work with, and values for each of those properties, the logic can become a bit more complex.

One way to handle it would be to not handle it up front, but to use some error handling, and catch any errors if they occur due to incomplete/invalid data. 

While that will technically work... you can catch invalid or incomplete data another way. 

In this example we'll be using the CSV file from earlier, and it's contents (with one addition, a test user with some missing information).

Here's our imported test data:

I'll now use the following code to validate the data:

#Iterate through each user
ForEach ($user in $users) {

    #Declaring variables up top
    $userProperties = $null
    $skipUser       = $null

    #Setting the $userProperties variable to contain the properties of the current $user object
    $userProperties = $user | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name
    #Setting $skipUser to $false by default 
    $skipUser       = $false

    #Now we will iterate through each property
    ForEach ($property in $userProperties) {
    
        #For the current $property, we're using a switch statement to evaulute the $user object's current value
        #We do this by accessing $user.$property
        Switch ($user.$property) {

            #If there are any non-alphanumeric characters, minus spaces, set $skipUser to $true
            #We then break out of the switch
            {$_ -match '\W' -and $_ -notmatch '\s'} {
        
                $skipUser = $true

                Break

            }

            #If any of the values are blank, set $skipUser to $true
            #We then break out of the switch
            {$_ -eq ''} {

                $skipUser = $true

                Break

            }

        }
        
    }

    #If $skipUser is $false, perform this action
    if (!$skipUser) {

        Write-Host "Processing $($user | Out-String)"
        #Processing code here

    #Else (basically if $skipUser is $true), perform this action
    } else {

        Write-Host "Skipping $($user | Out-String)"
        #Skipping code here

    }

}

I use a couple different loops, a switch statement, regex, and an if statement to validate the data. I've added comments to the above code so you can better understand what is happening.

Let's take a look at the results:

Perfect! The two that we'd expect to be skipped, are indeed skipped.

Iterating Through Data

Iterating through data in PowerShell typically involves using loops. While I have gone over loops in a previous article, I will cover how they apply to our examples above.

Text Files

Let's expand upon our example from validating Text Files above. We have the following cities in $Cities:

Let's iterate through them, and just for fun, return some information from Weather Underground's free autocomplete API for each one.

[System.Collections.ArrayList]$apiDataArray = @()

ForEach ($city in $Cities) {

    $apiURL         = $null
    $cityName       = $null
    $apiData        = $null
    $cityDataObject = $null

    $cityName       = $city
    $apiURL         = "http://autocomplete.wunderground.com/aq?query=$cityName"
    $apiData        = Invoke-RestMethod -Uri $apiURL

    $cityDataObject = [PSCustomObject]@{

        CityName = $cityName
        APIData  = $apiData

    }

    $apiDataArray.Add($cityDataObject) | Out-Null

}

In the above example I use a ForEach loop, Invoke-RestMethod, and a PS Custom Object.

Let's take a look at the results in $apiDataArray!

We now have an array of cities, and their associated API data. Let's dig into the data for Chicago.

Hmm, looks like we'll need to access the RESULTS property.

The API returned the results in an array for what it found when we gave it the value of Chicago for the city name. The first object in the array is the closest match. Let's access that!

There we go, data we can use.

CSV/CliXML Files

We'll be using the CSV information we validated earlier in this example. We pretty much demonstrated iterating through it when we validated it. However, I'd like to take that example further to demonstrate a few things, and put a lot of the concepts I've been going over together!

For this example I will iterate through the contents of the CSV file, and perform the following actions:

  • Create an array to store objects in
  • Validate information
  • Attempt to create the user in my Active Directory lab environment
  • Capture all results in an object
  • Add the object to the array
  • Return the object array

Here is the code (I've added comments so you can understand what is happening, every step of the way):

#Import users and create an array to store the results in
$Users                                      = Import-CSV .\ADUsers.csv
[System.Collections.ArrayList]$resultsArray = @()

#if $Users exists
if ($Users) {

    #Iterate through each user
    ForEach ($user in $users) {

        #Declaring variables up top
        $userProperties = $null
        $skipUser       = $null
        $samAccountName = $null
        $createUser     = $null
        $userName       = $null
        $resultsObject  = $null
        $csvValidation  = $null

        #Setting the $userProperties variable to contain the properties of the current $user object
        $userProperties = $user | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name

        #Setting $skipUser to $false by default 
        $skipUser       = $false

        #Now we will iterate through each property
        ForEach ($property in $userProperties) {
    
            #For the current $property, we're using a switch statement to evaulute the $user object's current value
            #We do this by accessing $user.$property
            Switch ($user.$property) {

                #If there are any non-alphanumeric characters, minus spaces, set $skipUser to $true
                #Set $csvValidation to the property we found an issue with
                #We then break out of the switch
                {$_ -match '\W' -and $_ -notmatch '\s'} {
        
                    $skipUser      = $true
                    $csvValidation = "Invalid characters in [$property]"

                    Break

                }

                #If any of the values are blank, set $skipUser to $true
                #Set $csvValidation to the property we found an issue with
                #We then break out of the switch
                {$_ -eq ''} {

                    $skipUser      = $true
                    $csvValidation = "Empty value for [$property]"

                    Break

                }

            }
        
        }

        #If $skipUser is $false, perform this action
        if (!$skipUser) {
        
            #Set the name of the user in $userName using first and last name from CSV
            $userName = "$($user.'First Name') $($user.'Last Name')"

            #Set $adServer with the hostname of a domain controller
            $adServer = (Get-ADDomainController).HostName

            Write-Host "Creating AD account for $userName..."`n

            #Set $samAccountName variable with first initial+lastName.
            $samAccountName = ($user.'First Name'.Substring(0,1) + $user.'Last Name').ToLower()
     
            Try {

                #Store created AD User object results in $createUser via New-ADUser with -PassThru, then use that with the other AD commands
                $createUser = New-ADUser -Company:$user.Company -Department:$user.Department -DisplayName:$userName -GivenName:$user.'First Name' -Name:$userName -Path:"OU=Users,OU=Accounts,DC=gngrninja,DC=com" -SamAccountName:$samAccountName -Server:$adServer -Surname:"$($user.'Last Name')" -Type:"user" -UserPrincipalName:"$samAccountName@gngrninja.com" -PassThru
                
                #Set the password for the account. You would want to use a more secure method than this for the password, however :). Flag for reset on new logon
                Set-ADAccountPassword -Identity:$createUser -NewPassword:('p@$$w0rd' | ConvertTo-SecureString -AsplainText -Force) -Reset:$true -Server:$adServer
                
                #Set more password options
                Set-ADAccountControl  -Identity:$createUser -AccountNotDelegated:$false -AllowReversiblePasswordEncryption:$false -CannotChangePassword:$false -DoesNotRequirePreAuth:$false -PasswordNeverExpires:$false -Server:$adServer -UseDESKeyOnly:$false
                
                #Enable the account
                Enable-ADAccount      -Identity:$createUser -Server:$adServer
                
                #Perform action if account exists in AD
                if (Get-ADUser $createUser -Server $adServer) {

                    Write-Host "User [$($createUser.Name)] verified to exist in AD!"`n -ForegroundColor Green -BackgroundColor Black

                    $resultsObject = [PSCustomObject]@{

                        UserName         = $createUser.Name
                        AccountCreated   = $true 
                        ErrorMessage     = 'N/A'

                    }

                #Perform action if account is not found in AD
                } else {

                    Write-Host "User [$userName] not found in AD... something went wrong!"`n -ForegroundColor Red -BackgroundColor DarkBlue

                    $resultsObject = [PSCustomObject]@{

                        UserName         = $userName
                        AccountCreated   = $false
                        ErrorMessage     = 'Account not found in AD!'

                    }

                }

                #Add results object to $resultsArray
                $resultsArray.Add($resultsObject) | Out-Null

            } 
            
            #Catch account already exists exception
            Catch [Microsoft.ActiveDirectory.Management.ADIdentityAlreadyExistsException] {

                Write-Host "User [$userName] already exists in AD!"`n -ForegroundColor Red -BackgroundColor DarkBlue
                
                $resultsObject = [PSCustomObject]@{

                        UserName         = $userName
                        AccountCreated   = $false
                        ErrorMessage     = 'Account already exists in AD!'

                }

                #Add results object to $resultsArray
                $resultsArray.Add($resultsObject) | Out-Null

            }

            Catch {

                Write-Host "Error [$($_.Exception.Message)] occured when attempting to create [$userName]!"`n -ForegroundColor Red -BackgroundColor DarkBlue
                
                $resultsObject = [PSCustomObject]@{

                        UserName         = $userName
                        AccountCreated   = $false
                        ErrorMessage     = $_.Exception.Message

                }

                #Add results object to $resultsArray
                $resultsArray.Add($resultsObject) | Out-Null 
                               
            }

        #Else (basically if $skipUser is $true), perform this action
        } else {

            $userName = "$($user.'First Name') $($user.'Last Name')"
            Write-Host "Skipping $userName..."

            $resultsObject = [PSCustomObject]@{

                    UserName         = $userName
                    AccountCreated   = $false
                    ErrorMessage     = "Error validating data: [$csvValidation]!"

             }

             #Add results object to $resultsArray
             $resultsArray.Add($resultsObject) | Out-Null
        
        }

    }

}

#Return the $resultsArray array of objects
Return $resultsArray

Let's run this on a remote session to my lab's Domain Controller, and see what happens! I've saved the script as Invoke-NewUserCreation.ps1, in a directory that also contains a CSV of the users.

Since I return the object array of results, I will use the following command to run the script and store the results:

$createUsers = .\Invoke-NewUserCreation.ps1

There you can see some of what is happening, because I used the evil Write-Host command to display information usefully :)

However, the real power comes with the object we have now (which has detailed results). Let's take a look at $createUsers.

$createUsers | Format-Table -AutoSize

Here you can see all the information regarding what happened. We can even see where the first validation errors occurred for the users that were skipped.

Here is a screenshot of Active Directory Users and Computers on that Domain, showing the results:

Let's run the script again, to see what happens!

$createUsers = .\Invoke-NewUserCreation.ps1

Those errors were to be expected! Now let's see the object...

$createUsers | Format-Table -AutoSize

Now the object contains the error message stating the user account already exists.

Exporting Data

We've gone over importing, validating, and iterating through data. Now let's see what we need to do to export it!

Text Files

This is the simplest of the bunch. In this example I will use Get-Process to get a list of process names, and then export that to Processes.txt.

Get-Process | Select-Object -ExpandProperty Name | Out-File .\Processes.txt

Let's make sure the file exists, and that it has content!

Sure enough, it does! 

CSV Files

Exporting to CSV allows for better readability, and also works to import the data into other systems. The readability can also be used for reporting purposes.

Here is the $createUsers object we created earlier:

Let's export that to a CSV via the Export-Csv command. We will want to be sure to use the parameter -NoTypeInformation, as that will prevent PowerShell from also outputting the object's top information on top of the CSV (which we don't want!).  

$createUsers | Export-Csv -NoTypeInformation (".\ADresults{0:MMddyy_HHmm}.csv" -f (Get-Date))

Let's see if the file exists...

There it is! Let's take a look at the contents.

We have data, and it is ours to pretty up.

CliXML Files

I love CliXML files. I use them a lot, and they can really save you some time. We'll use the $createUsers object we used earlier, which contains the following information:

Now I will export that to an XML file via Export-Clixml.

$createUsers | Export-Clixml (".\ADresults{0:MMddyy_HHmm}.xml" -f (Get-Date))

Let's ensure the file exists:

For good measure, let's import the Clixml file and then compare the original object and imported object side-by-side:

$createUsersCopy = Import-Clixml .\ADresults062716_2041.xml

There you have it, they are identical! The CliXML commands provide an excellent method for storing PowerShell objects.

Homework

  • How could we have automatically made the CSV iteration example export the results to a CSV?
    • How about to CliXML?
  • What ways can you benefit from validation in your code?

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to Top]

PowerShell: Getting Started - Creating Custom Objects

PowerShell: Getting Started - Creating Custom Objects

Getting Started - Creating Custom Objects

Welcome to my Getting Started with Windows PowerShell series!

What Are Custom Objects in PowerShell?

Everything we work with in PowerShell is an object in one way or another. A simple string is an object with the type of [System.String], using Get-Process returns objects with the type of [System.Diagnostics.Process], and using Get-ChildItem returns objects with the type [System.IO.FileInfo]

We can see that, here.

Custom objects are objects we create to collect data we want to collect. They can be comprised of objects from other datasets, arrays, and commands. Anything we can do in PowerShell can be fed into a custom object.

While we can create any type of object, from COM objects to objects based on .NET classes, we'll be focusing on the PSCustomObject. PSCustomObject is a .NET class [System.Management.Automation.PSCustomObject].

Why Create Custom Objects?

So that's nice... we can create custom objects, but why do it? Simple... output. In PowerShell it is best to keep your output from scripts and functions as objects you create or handle.  This allows for better readability and re-use. 

That way you can collect information, and iterate through it with built in commands such as Select-Object and Where-Object.

Since we can add anything to a custom object, from any other object/source in PowerShell, it can also be used as a method to organize information.

Let's take a look at a few example of creating custom objects.

Creating Custom Objects

Below I will be creating custom objects using various methods. It is important to understand that there isn't necessarily a best way to do anything in PowerShell. At the end of the day, the best way is the way that gets the job done for you.

However, I tend to gravitate towards methods that are easy for people looking at my scripts to interpret. This applies even when I'm looking at my own scripts! If I'm tired, and looking at some of my code, the last thing I need to do is try to figure out what I was doing based on code that isn't that easy to read.

Feel free to follow along! Open up the ISE and save a file to C:\PowerShell named part12.ps1. I will use the following code each time an example executes:

$adminPasswordStatus = $null
$thermalState        = $null
$osInfo              = Get-CimInstance Win32_OperatingSystem
$computerInfo        = Get-CimInstance Win32_ComputerSystem
$diskInfo            = Get-CimInstance Win32_LogicalDisk

Switch ($computerInfo.AdminPasswordStatus) {

    0 {$adminPasswordStatus = 'Disabled'}
     
    1 {$adminPasswordStatus = 'Enabled'}

    2 {$adminPasswordStatus = 'Not Implemented'} 

    3 {$adminPasswordStatus = 'Unknown'}

    Default {$adminPasswordStatus = 'Unable to determine'}

}

Switch ($computerInfo.ThermalState) {

    1 {$thermalState = 'Other'}

    2 {$thermalState = 'Unknown'}

    3 {$thermalState = 'Safe'}

    4 {$thermalState = 'Warning'} 

    5 {$thermalState = 'Critical'}

    6 {$thermalState = 'Non-recoverable'}

    Default {$thermalState = 'Unable to determine'}

}

Be sure to include the code above with any example you want to try in the ISE.

New-Object With Add-Member

With this method the first thing we'll do is create the object with the New-Object command, and store the object in a variable. Then we'll pipe the object to the Add-Member command. Add-Member will add the properties or methods we specify to the object we create.

We'll be using the MemberType of NoteProperty, and then give names to the properties (the names are whatever we want them to be). It's good to make them make sense! Then, finally, we'll define the Value as whatever expression we need to. In this case we'll use the some of the variables above. If you're following along and didn't catch what we have in the variables, check the summary below.

$computerInfo contains information from Get-CimInstance Win32_ComputerSystem.
$osInfo contains information from Get-CimInstance Win32_OperatingSystem.
$diskInfo contains information from Get-CimInstance Win32_LogicalDisk.

Here's the code:

$ourObject = New-Object -TypeName psobject 

$ourObject | Add-Member -MemberType NoteProperty -Name ComputerName -Value $computerInfo.Name
$ourObject | Add-Member -MemberType NoteProperty -Name OS -Value $osInfo.Caption
$ourObject | Add-Member -MemberType NoteProperty -Name 'OS Version' -Value $("$($osInfo.Version) Build $($osInfo.BuildNumber)")
$ourObject | Add-Member -MemberType NoteProperty -Name Domain -Value $computerInfo.Domain
$ourObject | Add-Member -MemberType NoteProperty -Name Workgroup -Value $computerInfo.Workgroup
$ourObject | Add-Member -MemberType NoteProperty -Name DomainJoined -Value $computerInfo.Workgroup
$ourObject | Add-Member -MemberType NoteProperty -Name Disks -Value $diskInfo
$ourObject | Add-Member -MemberType NoteProperty -Name AdminPasswordStatus -Value $adminPasswordStatus
$ourObject | Add-Member -MemberType NoteProperty -Name ThermalState -Value $thermalState

Now let's run that, and then type $ourObject to see what our object contains.

That worked! We created an object, which contains information from all three sources. We can type $ourObject.Disks to see what is contained there.

Hashtable Using Add Method

Now let's take a look at using a hashtable to add our properties and values to the custom object. A hashtable is a collection of keys that have names with associated values.

For this example I will create the hashtable, and then call its Add method to add the pairs of data (separated by a comma). With this method we will actually create the object last, as we'll be adding the hashtable of properties and values to it when we create it.

If you're following along and didn't catch what we have in the variables, check the summary below.

$computerInfo contains information from Get-CimInstance Win32_ComputerSystem.
$osInfo contains information from Get-CimInstance Win32_OperatingSystem.
$diskInfo contains information from Get-CimInstance Win32_LogicalDisk.

Here's the code:

[hashtable]$objectProperty = @{}

$objectProperty.Add('ComputerName',$computerInfo.Name)
$objectProperty.Add('OS',$osInfo.Caption)
$objectProperty.Add('OS Version',$("$($osInfo.Version) Build $($osInfo.BuildNumber)"))
$objectProperty.Add('Domain',$computerInfo.Domain)
$objectProperty.Add('Workgroup',$computerInfo.Workgroup)
$objectProperty.Add('DomainJoined',$computerInfo.PartOfDomain)
$ObjectProperty.Add('Disks',$diskInfo)
$objectProperty.Add('AdminPasswordStatus',$adminPasswordStatus)
$objectProperty.Add('ThermalState',$thermalState)

$ourObject = New-Object -TypeName psobject -Property $objectProperty

Now let's see what $ourObject contains.

The code looks cleaner, but notice the order of the properties! It's not in the order we created the hashtable in. With Add-Member we were able to add each property, and it would keep the order we added them in.

That's not really a big deal, though! Since PowerShell has the nifty Select-Object command, we have an easy way to re-order the object.  

Let's try:

$ourObject | Select-Object ComputerName,OS,'OS Version',Domain,Workgroup,DomainJoined,AdminPasswordStatus,ThermalState,Disks

There we go, the order is as we want it now. Note that this will work to re-order any object you pass to it, and this can really come in handy.

Ordered Hashtable

!NOTE! Using this method is only supported in PowerShell version 3.0+

In this example I will declare the hashtable all at once, and then add the hashtable to the custom object we create. I will also use an ordered hashtable, so we can see how those work. That should allow us to keep the order of the properties correctly in the object. 

Here's the code:

$objectProperty = [ordered]@{

    ComputerName        = $computerInfo.Name
    OS                  = $osInfo.Caption
    'OS Version'        = $("$($osInfo.Version) Build $($osInfo.BuildNumber)")
    Domain              = $computerInfo.Domain
    Workgroup           = $computerInfo.Workgroup
    DomainJoined        = $computerInfo.PartOfDomain
    Disks               = $diskInfo
    AdminPasswordStatus = $adminPasswordStatus
    ThermalState        = $thermalState

}

$ourObject = New-Object -TypeName psobject -Property $objectProperty

$ourObject

Let's run this [F5], and see what happens!

Looks good, and it is indeed in the right order!

Running the script from the console shows the object in the correct order as well.

PSCustomObject Type Adapter

!NOTE! Using this method is only supported in PowerShell version 3.0+

We can also create custom objects using the [PSCustomObject] type adapter.

If you're following along and didn't catch what we have in the variables, check the summary below.

$computerInfo contains information from Get-CimInstance Win32_ComputerSystem.
$osInfo contains information from Get-CimInstance Win32_OperatingSystem.
$diskInfo contains information from Get-CimInstance Win32_LogicalDisk.

Here's the code:

$ourObject = [PSCustomObject]@{

    ComputerName        = $computerInfo.Name
    OS                  = $osInfo.Caption
    'OS Version'        = $("$($osInfo.Version) Build $($osInfo.BuildNumber)")
    Domain              = $computerInfo.Domain
    Workgroup           = $computerInfo.Workgroup
    DomainJoined        = $computerInfo.PartOfDomain
    Disks               = $diskInfo
    AdminPasswordStatus = $adminPasswordStatus
    ThermalState        = $thermalState

}

Let's take a look at $ourObject.

Nice! It also kept our order both in the ISE, and the console.

Here are the results from running part12.ps1 in the console:

Same result. Now let's look at how we can use this code to create an array of custom objects.

Creating a Custom Object Array

Why would we want to do such a thing?

Let's look at the following scenario:

You have a list of computers that you need to get information from. You want to capture any error messages that may come up. You also would like to capture what the computer name in the file was (which we will as ComputerInput), as well as the computer name returned when querying for information. We'll also want to gather the operating system and disk information.

To do this, we can use a ForEach loop, which calls a function, and utilize Try/Catch for error handling.

We'll also use a lot of what we learned today about creating custom objects. 

Setup

There is some setup to this, which is not included in the code. 

$credential contains a [System.Management.Automation.PSCredential] object, which stores the credentials to access my laptop for this example.

$computers contains an array of computer names to look up. For this example, I created a text file, and used Get-Content to import it into the $computers variable.

Looks like there are some potential script breakers in there!

Let's look at the code:

I've commented out the code to go over each and every step along the way. 

function Get-ComputerInformation { #Begin function Get-ComputerInformation
    [cmdletbinding()]
    param (
        [Parameter(
            Mandatory = $true
        )]
        [String]
        $computerName
    )

    Try { #Begin Try
        
        #Using Write-Host as Verbose is too chatty with the other commands
        Write-Host `n"Looking up information for: [$computerName]"`n

        #Null out variables used in the function
        #This isn't needed, but I like to declare variables up top
        $adminPasswordStatus = $null
        $thermalState        = $null
        $cimSession          = $null
        $computerObject      = $null
        $errorMessage        = $null

        #Except this guy, he gets a value that we'll use when errors are encountered
        $unableMsg           = 'Unable to determine'

        #Switch out the incoming $computerName
        #If it's localhost, we wont need to use our $credential variable when creating our CimSession
        #This creates the $cimSession variable, which encapsulates the object created with New-CimSession pointed at the target $computerName
        Switch ($computerName) {

            'localhost' {$cimSession = New-CimSession -ComputerName $computerName -ErrorAction Stop}

             Default    {$cimSession = New-CimSession -Credential $credential -ComputerName $computerName -ErrorAction Stop}

        }

        #Gather information using Get-CimInstance, pointed to our $cimSession variable
        #ErrorAction is set to Stop here, so we can catch any errors
        $osInfo       = Get-CimInstance Win32_OperatingSystem -CimSession $cimSession -ErrorAction Stop
        $computerInfo = Get-CimInstance Win32_ComputerSystem  -CimSession $cimSession -ErrorAction Stop
        $diskInfo     = Get-CimInstance Win32_LogicalDisk     -CimSession $cimSession -ErrorAction Stop

        #Use a switch to get the text value based on the number in $computerInfo.AdminPasswordStatus
        Switch ($computerInfo.AdminPasswordStatus) {

                  0 {$adminPasswordStatus = 'Disabled'}
     
                  1 {$adminPasswordStatus = 'Enabled'}

                  2 {$adminPasswordStatus = 'Not Implemented'}

                  3 {$adminPasswordStatus = 'Unknown'}

            Default {$adminPasswordStatus = 'Unable to determine'}

        }

        #Use a switch to get the text value based on the number in $computerInfo.ThermalState
        Switch ($computerInfo.ThermalState) {

                  1 {$thermalState = 'Other'}

                  2 {$thermalState = 'Unknown'}

                  3 {$thermalState = 'Safe'}

                  4 {$thermalState = 'Warning'} 
             
                  5 {$thermalState = 'Critical'}

                  6 {$thermalState = 'Non-recoverable'}

            Default {$thermalState = 'Unable to determine'}

        }

        #Create the object, cleanly!
        $computerObject = [PSCustomObject]@{

            ComputerInput       = $computerName
            ComputerName        = $computerInfo.Name
            OS                  = $osInfo.Caption
            'OS Version'        = $("$($osInfo.Version) Build $($osInfo.BuildNumber)")
            Domain              = $computerInfo.Domain
            Workgroup           = $computerInfo.Workgroup
            DomainJoined        = $computerInfo.PartOfDomain
            Disks               = $diskInfo
            AdminPasswordStatus = $adminPasswordStatus
            ThermalState        = $thermalState
            Error               = $false
            ErrorMessage        = $null

        }

        #Close the CimSession
        Remove-CimSession -CimSession $cimSession -ErrorAction Stop

        #Return the object created
        Return $computerObject
            
    } #End Try

    Catch { #Begin Catch

        #Capture the exception message in the $errorMessage variable
        $errorMessage = $_.Exception.Message    
        
        #Create our custom object with the error message     
        $computerObject = [PSCustomObject]@{

            ComputerInput       = $computerName
            ComputerName        = $unableMsg
            OS                  = $unableMsg
            'OS Version'        = $unableMsg
            Domain              = $unableMsg
            Workgroup           = $unableMsg
            DomainJoined        = $unableMsg
            Disks               = $unableMsg
            AdminPasswordStatus = $unableMsg
            ThermalState        = $unableMsg
            Error               = $true
            ErrorMessage        = $errorMessage
              
        }

        #Keeping Write-Host here for the colors! Hate all you want ;)
        Write-Host `n"Error encountered [$errorMessage]!"`n -ForegroundColor Red -BackgroundColor DarkBlue

        #Return the object created
        Return $computerObject 
            
        #Stop processing commands
        Break

    } #End Catch

} #End function Get-ComputerInformation

#Create the array we'll add the objects to
[System.Collections.ArrayList]$computerArray = @()

#Iterate through each computer in $computers with a ForEach loop
ForEach($computer in $computers) {

    #Use the Add method of the ArrayList to add the returned object from the Get-ComputerInformation function 
    #Piping this to Out-Null is important to suppress the result output from adding the object to the array
    $computerArray.Add((Get-ComputerInformation -computerName $computer)) | Out-Null

}

#Display the contents of the array.
$computerArray

Let's fire up the console, run the script, and take a look at the results!

It looks like the returned object is indeed an array of objects we've created in the function.

To do this I created an array before I called the ForEach loop, and then used the Add method to call the function with the current $computerName in the loop. This returns the object as a result, when each loop iteration runs.

We can also create a variable to store the results of the script via:

$computerArray = .\part12.ps1

You can see that using $computerArray[0] returned the first object in the array.

Want to export this to a CSV? Use this command:

$computerArray | Export-CSV .\results.csv -noTypeInformation

Let's open the file, and see if we were successful...

Now we can format it as we see fit!

!Note! Enumerating the disks and exporting them to the CSV is a bit more tricky as it is actually an object within an object.

This is but one of many ways to use objects! Again, there is no one right way. The right way is whatever works for you and your use case! 

Homework

  • How could you create a custom object that contains the computer name, along with network adapter, and IP information?
  • What interesting ways could you combine error handling with custom objects?
  • How can we properly export the disk information as well (from the custom object array example)?

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to top]

PowerShell: Getting Started - Error Handling

PowerShell: Getting Started - Error Handling

Getting Started - Error Handling

Welcome to my Getting Started with Windows PowerShell series!

Why Handle Errors?

Error handling may take some extra time to create, however, it can really save you time in the long run. Let's say you have a script that performs the following tasks:

  • Takes information from a database / file and stores it in $userAccounts
  • Uses a loop to perform processing on each $user in $userAccounts
    • Update a user's AD information if the user is in the list
    • Disable any accounts that are not in the list

The script runs, and seems to be working great for a few weeks. Then, all of a sudden, the database server goes down, and $userAccounts is empty. The script runs, doesn't see any users, and what likely happens next is all user accounts are deleted. While this example is a little extreme, it is something that can happen. There are also a lot of in-between cases that can make error handling worth it. It's a good mindset to have when you're writing scripts as you can write to incorporate it off the bat.

Errors in PowerShell

Errors in PowerShell are stored in the automatic variable $error. You can see how many errors you've encountered in your session by using:

$error.count

This is essentially an array of errors, to access the first value you can use:

$error[0]

Let's take a look at $error[0] more closely, via:

$error[0] | Get-Member

We can view information on the command that raised the error via:

$error[0].InvocationInfo

It looks like the line property contains the full command we used when the error was encountered.

$error[0].InvocationInfo.Line

The exception that raised the error can be accessed via:

$error[0].Exception

We can also get more information about the exception via:

$error[0].Exception | Get-Member

Here you can see the TypeName: [System.UnauthorizedAccessException], and the various methods and properties of the exception. The exception TypeName is used later when we catch specific errors.

Looks like to get the exception's message in string format we'd use:

$error[0].Exception.Message

Now that we've dug into the automatic variable $error, let's move on to the different types of errors we can encounter.

Terminating Errors

Terminating errors in PowerShell mean that the script can no longer continue to run with the information it has encountered, or has been given. If you do not have a way to catch the error, it will likely display the nasty red error text you've likely seen before. Terminating errors halt anything running in the pipeline. This terminates output, aside from the error message of the terminating error. 

Here's an example of a terminating error that was caused by calling a command that does not exist:

Get-TerminatingError

Non-Terminating Errors

Non-terminating errors in PowerShell do not stop the pipeline from executing. These errors are handled internally by the Cmdlet, and are not able to be captured via error handling. There are ways, however, to make PowerShell treat non-terminating errors like terminating errors. That way we can use error handling to capture what's going on.

Here's an example of a non-terminating error (access denied to a subfolder), when attempting to list all folders and subfolders in "C:\Windows\appcompat\".

Get-ChildItem -Path 'C:\Windows\appcompat' -Recurse

Force Non-Terminating Errors to Terminate

You can force non-terminating errors to become terminating errors in PowerShell. There are a couple ways you can do this. Why would you want to do this, you ask? Since non-terminating errors cannot be handled by us, we need to force them to terminate, and gain the functionality of handling the error ourselves. This is not always needed, but it is good to know that you can do it if you come across a use case for yourself.

$errorActionPreference

You can do it at a global way for the session via the $ErrorActionPreference variable. This is a special variable in PowerShell that allows you to control what happens when a non-terminating error is encountered.

Here are the values for $ErrorActionPreference.

  • Stop
    • Display error, and stop execution.
  • Inquire
    • Display error, and ask to continue.
  • Continue (Default)
    • This is the default setting. Display error, then continue execution.
  • Suspend
    • This one is for workflows. A workflow job is suspended to investigate what happened, then the workflow can be resumed.
  • SilentlyContinue
    • No error is displayed, execution is continued.

Let's see this in action.

Get-ChildItem -Path 'C:\Windows\appcompat' -Recurse;Write-Host 'Test'

As you can see, with a non-terminating error, the next command in the sequence is executed. Therefore we see 'Test'. Let's set the $errorActionPreference automatic variable to Stop, and re-run the same command.

$ErrorActionPreference = 'Stop'
Get-ChildItem -Path 'C:\Windows\appcompat' -Recurse;Write-Host 'Test'

It worked! This would also let us use error handling with this error in PowerShell now. As you can see, we do not see the output of the next command, as it never ran.

Use the Command's -ErrorAction Parameter

Cmdlet's and functions/scripts/modules that use [cmdletbinding()] enable utilization of the -ErrorAction common parameter. This parameter allows you to specify different actions to take when an error is encountered. 

  • Stop
    • Display error, and stop execution.
  • Inquire
    • Display error, and ask to continue.
  • Continue (Default)
    • This is the default setting. Display error, then continue execution.
  • Suspend
    • This one is for workflows. A workflow job is suspended to investigate what happened, then the workflow can be resumed.
  • SilentlyContinue
    • No error is displayed, execution is continued.
  • Ignore
    • The same as SilentlyContinue, but as SilentlyContinue still adds the message to the $error automatic variable, Ignore does not do this.

Let's set our $errorActionPreference to Continue, and then look at using the Cmdlet Get-ChildItem's -ErrorAction parameter.

We're setting $errorActionPreference back to Continue as earlier we set it to Stop for our other example. Continue is the default value.

$ErrorActionPreference = 'Continue'
Get-ChildItem -Path 'C:\Windows\appcompat' -Recurse -ErrorAction Stop;Write-Host 'Test'

Error Handling

There are a few different ways to to handle errors in PowerShell. The best way of course, is to never let them happen in the first place! To do that, it is good to have some data validation in place via an if statement, or whichever method suites your needs.

If a terminating error occurs, we can use Try/Catch/Finally blocks to perform different actions. If it is non-terminating, we can force it to become a terminating error, and then choose how to continue.

Validation

The simplest method of validation is the if statement. 

If you'd like to run these examples yourself, go ahead and fire up the PowerShell ISE. Save a file in C:\PowerShell as part11.ps1. 

An if statement is constructed as follows:

if (condition -eq $true) {

Do-Stuff

} else {

Do-Other-Stuff

}

The condition can be anything that resolves to a true value. That includes running a command that output (other than and error) occurs! Check this out:

if (Get-ChildItem Z:\ -ErrorAction SilentlyContinue) {

    Write-Host 'I can list the contents of Z:!'

} else {

    Write-Host 'I cannot list the contents of Z:!'

}

This does not return true, as expected, and thus we see the message from the code in the else portion of the if statement. We use the -ErrorAction common parameter with the value SilentlyContinue to suppress the error from being displayed to the end user of the script. No need in this case, as we're simply using the command for validation.

You can also use variables with the if statement to see if they are blank. 

$myVariable = $null

if ($myVariable) {

    Write-Host "We have information! Let's do stuff."

} else {

    Write-Host "`$myVariable is empty :("

}

The variable is empty, so the code in the else portion of the if statement is executed.

Try/Catch/Finally

The Try, Catch, and Finally blocks in PowerShell allow us to capture terminating errors. 

The Try block contains the code you'd like to execute, and catch any potential errors that happen.

The Catch block contains the code you'd like to execute after a terminating error has occurred. The current error will be accessible via the automatic variable $_.

The Finally block contains the code you'd like to run after the event has occurred. This is good for cleanup tasks. 

It is worth noting that finally block is not required.

Here is an example using Try/Catch/Finally:

Try {
    
    $command = 'Invoke-FakeCommand'

    Write-Host "Attempting to run: [Invoke-Expression -Command $command]"`n

    Invoke-Expression -Command $command

}

Catch {

    Write-Host $_.Exception.Message`n

}

Finally {

    Write-Host "Clean up: `$commmand = `$null"`n

    $commmand = $null

}

The code in the Try block executes and we see the output of Write-Host. We then see the error message that occurs, as our Catch block is writing out $_.Exception.Message. As we learned earlier, that is the string value of the exception that raised the error.

We then see the output from the Write-Host command in our Finally block. We use the finally block to free up the $command variable.

This version of Try/Catch will catch any terminating errors that are raised. To capture specific exceptions, you'll want to use the exception's TypeName.

Catch Specific Errors

Let's take a look at the following:

Try {

    Get-ThisWontWork

}

Catch [System.Management.Automation.CommandNotFoundException] {
    
    Write-Host "Command not found!"`n -ForegroundColor Red 
    Write-Host "Message: [$($_.Exception.Message)"] -ForegroundColor Red -BackgroundColor DarkBlue
    
}

The exception was caught, and the code in the Catch block was executed.

Now let's try this...

Try {
    
    Get-ChildItem -Path Z:\ -ErrorAction Stop
    Get-ThisWontWork

}

Catch [System.Management.Automation.CommandNotFoundException] {
    
    Write-Host "Command not found!"`n -ForegroundColor Red 
    Write-Host "Message: [$($_.Exception.Message)"] -ForegroundColor Red -BackgroundColor DarkBlue
    
}

Ahh! An error that wasn't handled! Luckily we can add multiple catch blocks.

Let's add another Catch block.

Catch {

Write-Host $_.Exception.Message

}
Try {
    
    Get-ChildItem -Path Z:\ -ErrorAction Stop
    Get-ThisWontWork

}

Catch [System.Management.Automation.CommandNotFoundException] {
    
    Write-Host "Command not found!"`n -ForegroundColor Red 
    Write-Host "Message: [$($_.Exception.Message)"] -ForegroundColor Red -BackgroundColor DarkBlue
    
}


Catch {

   Write-Host $_.Exception.Message

}

Now the exception from Get-ChildItem command is caught since the catch-all Catch block code is executed.

Getting Error Information

It can be handy to have a shortcut that shows you error information, which you can use to create specific Catch blocks. To do this, I created a function that I call in the Catch block which utilizes some validation itself! Here is the function:

function Get-ErrorInformation {
    [cmdletbinding()]
    param($incomingError)

    if ($incomingError -and (($incomingError| Get-Member | Select-Object -ExpandProperty TypeName -Unique) -eq 'System.Management.Automation.ErrorRecord')) {

        Write-Host `n"Error information:"`n
        Write-Host `t"Exception type for catch: [$($IncomingError.Exception | Get-Member | Select-Object -ExpandProperty TypeName -Unique)]"`n 

        if ($incomingError.InvocationInfo.Line) {
        
            Write-Host `t"Command                 : [$($incomingError.InvocationInfo.Line.Trim())]"
        
        } else {

            Write-Host `t"Unable to get command information! Multiple catch blocks can do this :("`n

        }

        Write-Host `t"Exception               : [$($incomingError.Exception.Message)]"`n
        Write-Host `t"Target Object           : [$($incomingError.TargetObject)]"`n
    
    }

    Else {

        Write-Host "Please include a valid error record when using this function!" -ForegroundColor Red -BackgroundColor DarkBlue

    }

}

Here is the full code I'll be running for this example:

function Get-ErrorInformation {
    [cmdletbinding()]
    param($incomingError)

    if ($incomingError -and (($incomingError| Get-Member | Select-Object -ExpandProperty TypeName -Unique) -eq 'System.Management.Automation.ErrorRecord')) {

        Write-Host `n"Error information:"`n
        Write-Host `t"Exception type for catch: [$($IncomingError.Exception | Get-Member | Select-Object -ExpandProperty TypeName -Unique)]"`n 

        if ($incomingError.InvocationInfo.Line) {
        
            Write-Host `t"Command                 : [$($incomingError.InvocationInfo.Line.Trim())]"
        
        } else {

            Write-Host `t"Unable to get command information! Multiple catch blocks can do this :("`n

        }

        Write-Host `t"Exception               : [$($incomingError.Exception.Message)]"`n
        Write-Host `t"Target Object           : [$($incomingError.TargetObject)]"`n
    
    }

    Else {

        Write-Host "Please include a valid error record when using this function!" -ForegroundColor Red -BackgroundColor DarkBlue

    }

}

Try {
   
    Get-ChildItem -Path Z:\ -ErrorAction Stop

    Get-ThisWontWork

}

Catch [System.Management.Automation.CommandNotFoundException] {

    Write-Host 'Command not found Catch block executed!' 

}

Catch {

   Get-ErrorInformation -incomingError $_

}

So now, if we wanted to catch this exception on it's own, we would need to add a catch block for [System.Management.Automation.DriveNotFoundException].

Catch [System.Management.Automation.CommandNotFoundException] {

Write-Host 'Command not found Catch block executed!' 

}

Let's add that to our code and run the following:

function Get-ErrorInformation {
    [cmdletbinding()]
    param($incomingError)

    if ($incomingError -and (($incomingError| Get-Member | Select-Object -ExpandProperty TypeName -Unique) -eq 'System.Management.Automation.ErrorRecord')) {

        Write-Host `n"Error information:"`n
        Write-Host `t"Exception type for catch: [$($IncomingError.Exception | Get-Member | Select-Object -ExpandProperty TypeName -Unique)]"`n 

        if ($incomingError.InvocationInfo.Line) {
        
            Write-Host `t"Command                 : [$($incomingError.InvocationInfo.Line.Trim())]"
        
        } else {

            Write-Host `t"Unable to get command information! Multiple catch blocks can do this :("`n

        }

        Write-Host `t"Exception               : [$($incomingError.Exception.Message)]"`n
        Write-Host `t"Target Object           : [$($incomingError.TargetObject)]"`n
    
    }

    Else {

        Write-Host "Please include a valid error record when using this function!" -ForegroundColor Red -BackgroundColor DarkBlue

    }

}

Try {
   
    Get-ChildItem -Path Z:\ -ErrorAction Stop

    Get-ThisWontWork

}

Catch [System.Management.Automation.CommandNotFoundException] {

    Write-Host 'Command not found Catch block executed!' 

}

Catch [System.Management.Automation.DriveNotFoundException] {

    Write-Host 'Get-ChildItem drive not found Catch block executed!'

}

Catch {

   Get-ErrorInformation -incomingError $_

}

There we go! Now all our errors are handled, minus the ones we don't know about yet.

Homework

  • Figure out why when multiple Catch blocks are used, it doesn't pass along the execution information (unable to get command/line).
    • Let me know why this is!
  • Where else could I have added error handling in any of these examples?

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to Top]