Modify Google Sheets (API) Using PowerShell / Uploading CSV Files

PowerShell Generated Google Sheet
PowerShell Generated Google Sheet

I recently had a need to interact with a Google Sheets spreadsheet and it turned out to be pretty easy and worked very well! I wanted to write this post to share what I learned and hopefully it can help some others out there.

In this guide we’ll explore how to create a Google API key then and use PowerShell to create a new spreadsheets and upload a CSV file into Google Sheets.

Acquiring Google Sheets API

First head to the official Google Developers Console. If you have never used the Google API before go ahead and create a new project. I called mine Scripts-jamesachambers.

After creating a project it should take you to the main dashboard. From here we are going to click “ENABLE APIS AND SERVICES” at the top of the page. It looks like this:

Google Sheets Dashboard - Enable APIs and Services
Google Sheets Dashboard – Enable APIs and Services

This will bring up a page showing all the different Google APIs available. From here we are going to scroll down to the G-Suite section and click on “Google Sheets” and then choose the “Enable” button.

Google Developer Console - Add API Menu
Google Developer Console – Add API Menu
Google Sheets Enable Button
Google Sheets – API Enable Button

Enable Google Drive API

Now enable the Google Drive API using the exact same steps. This will be needed for some access/permission controls later.

To enable the Google Drive API follow the same steps we did in the “Acquiring Google Sheets API” but choose “Google Drive” from the G-Suite options instead.

Creating a Service Account

There is a lot of security built into Google’s APIs. Some types of them require user consent but can access private user data. The kind of account we want to create to work with Google Sheets using scripts is a service account.

This type of account uses a certificate to validate your identity instead of a username/password. It is able to create new spreadsheets and give permissions for others to view them without requiring user consent since the service account will own the spreadsheet sidestepping the private user data issue. It is also able to update these sheets right from PowerShell!

When you clicked the “Enable” button it should have taken you into the “Google Sheets API Console” but sometimes I’ve noticed the button just turns into “Manage”. Click Manage of you aren’t already in the Google Sheets developer console. You should be at a screen that looks like this:

Google Sheets API Credentials
Google Sheets API – Credentials Tab

Choose the “Credentials” tab on the left hand of the screen as shown above and click “Create Credentials” and choose the “Service Account” option.

Choose a name for your account. You can set an optional description here as well if you would like. Click the “Create” button and it will take you to the next screen.

Google Sheets API - Service Account Roles
Google Sheets API – Service Account Roles

Give your service account the “Project” – “Owner” role. This will give your service account full access to all resources in the project you created. You can create finer grained permissions if desired but the service account is only going to have access to the project you created so it should be safe unless you have other resources in the project.

Press the button again and leave the final screen on the defaults. Your service account is now created!

Create Service Account Access Key/Certificate

Now that we have created our service account we need to create an access key and download the certificate for it.

At the official Google Developers Console dashboard choose the “Service Accounts” tab from the left side of the screen and then click on the new service account you created:

Google Sheets - API Service Accounts Menu
Google API Service Accounts Menu

Make a note or copy of your service account’s email address listed here. This will be the $iss credential we’ll be using in PowerShell to authenticate with Google very shortly.

At the bottom of your “Service account details” page will be an option to add keys. Click the “Add Key” button and choose “Create new key” as shown here:

Google Sheets - Create API Key
Google API Service Account Details – Create New Key

Choose the “P12” format:

Google Service Account - Create P12 Key
Google Service Account – Create P12 Key

Press “Create”:

Google Sheets - Download API Key
Google Service Account – Private Key Download

A download dialog will pop up and the screen above will be displayed. This is your certificate file your scripts will use to access Google Sheets.

Warning: This is the only time Google will give you this key. If you lose it you cannot recover it or redownload it and have to create a new key and update all your scripts that use that key accordingly. Make sure you have this key backed up!

Once you have safely backed up this key we are all done with configuration and it’s time to head to PowerShell!

Install UMN-Google PowerShell Module

We are going to use the UMN-Google PowerShell module to be able to communicate with Google Sheet’s API. You will want to install this module on any machine you will be running scripts. Open PowerShell and type:

Install-Module UMN-Google

This PowerShell module is developed and maintained by the University of Minnesota. You can see more information at the PowerShell Gallery here: The UMN-Google PowerShellGallery Repository

Getting Authorization Token

This block will appear at the top of your scripts. It will use our service account certificate to get an authorization code from Google.

Create a new PowerShell .ps1 file with the editor of your choice. Copy and paste the code block below:

Import-Module UMN-Google

# Set security protocol to TLS 1.2 to avoid TLS errors
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# Google API Authozation
$scope = "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file"
$certPath = "C:/Users/james/Desktop/PowerShell/jamesachambers-1551564735346.p12"
$iss = 'scripts@jamesachambers-1551564735346.iam.gserviceaccount.com'
$certPswd = 'notasecret'
try {
    $accessToken = Get-GOAuthTokenService -scope $scope -certPath $certPath -certPswd $certPswd -iss $iss
} catch {
    $err = $_.Exception
    $err | Select-Object -Property *
    "Response: "
    $err.Response
}

$accessToken

Modify the values below to match your certificate file you downloaded in the previous step and your $iss (service account email address) information that we made a note of/copied earlier. The 2 lines you need to change to your own values are listed in bold.

I recommend fully qualifying the path to your certificate file to avoid working path problems (especially when doing background/automation scheduled tasks). Make sure you remember to copy the certificate from your downloads folder to the location where you created your PowerShell .ps1 file and that you change the path to yours.

After you have modified the values run the script to make sure you don’t receive any errors. If everything ran successfully you will see a valid Google API authorization token! It should look like this:

Testing Google Sheets
PowerShell – Google API Authorization Test

If your screen shows a long string of numbers/letters/symbols and does not give an error then congratulations, you’ve completed your API key setup successfully! If you received an error double check that you copied the certificate and gave the right path and run through the earlier steps quickly and verify everything looks correct.

Create New Google Sheet

Now that you have working authentication it’s time for the fun part! Now we get to start creating/modifying Google Sheet documents. Let’s create a new sheet and display the spreadsheet ID.

Add the following commands to the end of your .ps1 file with the title of your choice:

# Create new spreadsheet
$Title = 'Patching Spreadsheet'
$SpreadsheetID = (New-GSheetSpreadSheet -accessToken $accessToken -title $Title).spreadsheetId
$SpreadsheetID

# Create new sheet
$Sheet = 'Computers'
Add-GSheetSheet -accessToken $accessToken -sheetName $Sheet -spreadSheetID $SpreadsheetID

Now run your .ps1 file again:

Power Shell Google Sheets New Sheet
PowerShell – Google Sheets New Spreadsheet

The first line is your access token again, but the second line is the new spreadsheet ID! To access this spreadsheet you append the spreadsheet ID on the end of the following base URL:

https://docs.google.com/spreadsheets/d/

So for example, to access the spreadsheet I just created in this example go to https://docs.google.com/spreadsheets/d/1anI7Bdr0ZjkSDwMbGpCb0KZc1SpSC-3JdQiycvoM5CM/

However, when you go to the URL you will see:

Google Sheets - You Need Access
Google Sheets – You Need Access

This is because by default these sheets are private. We need to set the permissions for other people to be able to access our new sheet!

Setting Google Sheet Permissions

Let’s start by adding individual users. Comment out the lines that create a new spreadsheet and sheet and add the following lines to the end of the file:

$SpreadsheetID = '1anI7Bdr0ZjkSDwMbGpCb0KZc1SpSC-3JdQiycvoM5CM'
Set-GFilePermissions -accessToken $accessToken -fileID $SpreadsheetID -role writer -type user -emailAddress 'youremail@yourdomain.com'

Change the $SpreadsheetID to the ID you received in the last step and the email to match your own personal email address. This will add “writer” permissions to your account to the spreadsheet. The bolded lines are things you need to adjust. The final result should look like this:

Import-Module UMN-Google

# Set security protocol to TLS 1.2 to avoid TLS errors
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# Google API Authozation
$scope = "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file"
$certPath = "C:/Users/james/Desktop/PowerShell/yourcertificate.p12"
$iss = 'scripts@jamesachambers-1551564735346.iam.gserviceaccount.com'
$certPswd = 'notasecret'
try {
    $accessToken = Get-GOAuthTokenService -scope $scope -certPath $certPath -certPswd $certPswd -iss $iss
} catch {
    $err = $_.Exception
    $err | Select-Object -Property *
    "Response: "
    $err.Response
}

$SpreadsheetID = '1anI7Bdr0ZjkSDwMbGpCb0KZc1SpSC-3JdQiycvoM5CM'

Set-GFilePermissions -accessToken $accessToken -fileID $SpreadsheetID -role writer -type user -emailAddress 'youremail@yourdomain.com'

Now run the script.

If you received the following error:

Invoke-RestMethod : The remote server returned an error: (403) Forbidden.

It means you very likely did not enable the “Google Drive” API. Go back to the “Enable Google Drive API” step from earlier and double check that the Google Drive API got enabled!

Now go back to your browser and refresh the “Access Denied” page. You should now see the blank spreadsheet!

Uploading a CSV File to Google Sheets

Now that you’ve set up your Google Sheets API keys and environment we are ready to modify new Google Sheets! If you already have a CSV file to upload you can use that, otherwise let’s create a quick testing CSV. Open Notepad or your favorite editor up to a new document and paste the following text:

Computer,"Operating System",RAM
"iMac 27""",OSX,"16 GB"
"HP ZBook 15""",Windows 10,"16 GB"

Now save the document as test.csv in the same directory you have our “Setting Google Sheets Permissions” PowerShell script from the last step. Remove or comment out the very last Set-GFilePermissions line of the script.

Google Sheets expects an ArrayList of values to upload to Google Sheets. We are going to load our test CSV and convert it to an ArrayList and submit the data to Google Sheets with the following code:

# Import CSV
$import = New-Object System.Collections.ArrayList($null)

# Build sheet header as it isn't included automatically
$import.Add( @("Computer", "Operating System", "RAM")) | Out-Null

# Build ArrayList
$inputCsv = Import-Csv "test.csv"
$inputCsv | ForEach-Object { 
    $import.Add( @($_.Computer, $_."Operating System", $_.RAM)) | Out-Null
}

# Upload CSV data to Google Sheets with Set-GSheetData
try {
    Set-GSheetData -accessToken $accessToken -rangeA1 "A1:C$($import.Count)" -sheetName "Computers" -spreadSheetID $SpreadsheetID -values $import -Debug -Verbose
} catch {
    $err = $_.Exception
    $err | Select-Object -Property *
    "Response: "
    $err.Response
}

This code loads the CSV into a variable called $inputCsv and then iterates through each object and adds it to the ArrayList we are going to send to Google Sheets. If you wanted to modify the data before sending it you absolutely can.

Once the CSV is imported you will have an object array and the names of the variables will be the name of the header of that column. You have to be careful if it has a quote in it. You can still access them by quoting the variable. For example:

$_."Operating System"

This will allow you to access the variables even if there are spaces in the column’s header. The actual first line of the CSV with the headers in it is consumed by PowerShell when you import it so we also create our own header line for our spreadsheet.

Your final code should look like this:

Import-Module UMN-Google

# Set security protocol to TLS 1.2 to avoid TLS errors
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# Google API Authozation
$scope = "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file"
$certPath = "C:/Users/james/Desktop/PowerShell/yourcertificate.p12"
$iss = 'scripts@jamesachambers-1551564735346.iam.gserviceaccount.com'
$certPswd = 'notasecret'
try {
    $accessToken = Get-GOAuthTokenService -scope $scope -certPath $certPath -certPswd $certPswd -iss $iss
} catch {
    $err = $_.Exception
    $err | Select-Object -Property *
    "Response: "
    $err.Response
}

# Spreadsheet ID # of the Google Sheet
$SpreadsheetID = '1anI7Bdr0ZjkSDwMbGpCb0KZc1SpSC-3JdQiycvoM5CM'

# Import CSV and build ArrayList
$import = New-Object System.Collections.ArrayList($null)
$inputCsv = Import-Csv "test.csv"
$inputCsv | ForEach-Object { 
    $import.Add( @($_.Computer, $_.'Operating System', $_.RAM)) | Out-Null
}

# Build sheet header as it isn't included automatically
$import.Add( @("Computer", "Operating System", "RAM")) | Out-Null

# Build ArrayList

# Upload CSV data to Google Sheets with Set-GSheetData
try {
    Set-GSheetData -accessToken $accessToken -rangeA1 "A1:C$($import.Count)" -sheetName "Computers" -spreadSheetID $SpreadsheetID -values $import -Debug -Verbose
} catch {
    $err = $_.Exception
    $err | Select-Object -Property *
    "Response: "
    $err.Response
}

Now we’re ready to run the code. Execute your script in PowerShell:

PowerShell - Test of uploading CSV to Google Sheets
PowerShell – Test of uploading CSV to Google Sheets

If everything was set up you should immediately see the changes in your browser window and it should look like this:

Patching Test Spreadsheet
Patching Test Spreadsheet

Wrapping Up

That’s it! You’ve now set up your Google API console access and can now modify Google Sheets through the power of PowerShell automation.

Make sure you check out the University of Minnesota’s GitHub repository to see any new changes or announcements related to the PowerShell extension. It is open source!

International Language Fix (Required for computers not set to “English” as the default language)

Once this post started gaining some traction I learned that international users (specifically ones that didn’t have “English” set as their default language) were having trouble with authentication. I suspected this was due to language differences because I’ve encountered it before on some of my other scripts (like my Minecraft Bedrock dedicated server setup script) that gained international traction as well and once we tested for it sure enough that was the issue.

Yoan graciously shared the solution in the comments:

Function Using-Culture (
[System.Globalization.CultureInfo]$culture = (throw "USAGE: Using-Culture -Culture culture -Script {scriptblock}"),
[ScriptBlock]$script= (throw "USAGE: Using-Culture -Culture culture -Script {scriptblock}"))
{
    $OldCulture = [System.Threading.Thread]::CurrentThread.CurrentCulture
    trap 
    {
        [System.Threading.Thread]::CurrentThread.CurrentCulture = $OldCulture
    }
    [System.Threading.Thread]::CurrentThread.CurrentCulture = $culture
    Invoke-Command $script
    [System.Threading.Thread]::CurrentThread.CurrentCulture = $OldCulture
}

This was found at Microsoft Developer Blogs – Using Culture Culture ScriptBlock and the blog post contains a ton of information about how to use it and what it’s doing!

In a nutshell it lets you wrap your calls to UMN-Google (or your entire script if you wanted) like this:

Using-Culture en-US {$accessToken = Get-GOAuthTokenService -scope $scope -certPath $certPath -certPswd $certPswd -iss $iss}

This is a really easy way to wrap your functions if you are getting errors related to language differences / default headers / etc. and is definitely worth a try if your PC’s default language is not set to English!

92 thoughts on “Modify Google Sheets (API) Using PowerShell / Uploading CSV Files”

  1. Avatar for michael

    Hey, one more question. It looks like I am only able to upload/download files that exist for this service account. My work gmail has tons of spreadsheets that I want to access but this service account is apparently isolated from it. Do you know of any solution to this? Thanks in advance!

    1. Avatar for James A. Chambers

      Hey michael,

      Welcome back! It’s generally just permissions related as is covered in the article (giving someone access by email address, etc.). You can actually create a “free tier” Google API account with your personal account to access it but it’s generally not too painful. Transferring documents in and out from the service account is a little tougher.

      However, there’s a caveat you may be running into in your situation. A lot of organizations that have enterprise-level agreements/contracts with Google severely restrict giving sharing permissions out to “work” documents at an enterprise level. This is to prevent both intentional and unintentional leaking of documents. I think it’s moving toward being the default setting for new “accounts” or “deployments” if it isn’t already.

      I ran into this a lot at my previous position which was in state government and when I left all my Google documents I’d ever touched were immediately locked away and inaccessible even to the accounts I’d granted permissions to. I wasn’t allowed to give permissions to my personal Gmail account as the organizational level had locked it down to specific approved organizations and *definitely* not personal accounts. This was not unique to me and is the state-wide policy for every state employee from the bottom to the top. I strongly suspect you may be running into intentional permissions walls like this. A lot of the time it may have not even been requested and may just be how the account was initially configured without much thought being put into it (especially for smaller organizations but certainly not exclusively).

      You’ll definitely want to use the Google API web console to see what kind of errors it’s throwing as the return codes from PowerShell (or even the API calls themselves) are extremely vague and the console will at least give you a short description of what errors are coming up. You can use the same trick to get the document ID of just loading it in the browser to get the spreadsheet IDs but getting the API will help to see if you’re allowed to do this.

      You may want to try sharing documents from within the official Google interfaces like sheets.google.com or any of the apps and see what happens with something like a personal Gmail account. If you have enterprise permissions restrictions you should get them in the web interface in a much nicer way.

      It might be easier to come up with a “Import-Spreadsheet” function or something like that and actually just have the service account recreate the spreadsheets (by just reading them and uploading the parsed data theoretically) and then have it programmatically add all of the users (usually by email) you want to have access to it.

      As far as doing this from within PowerShell or from code/using the API if we take a look here there is such a thing as a Move-GFile function as well as Get-GFile, Get-GFileID and some others that could really come in handy for that.

      If you want to go really deep I did also find this. This is in reference to both accessing files inside a shared domain (organizational) service account AND sharing it with THEM (which is part of what you want to do here). In short, you will have to “impersonate” users which is not as nefarious as it sounds and is in the technical sense (your OS processes impersonate/masquerade as other processes (usually the parent but not always) all the time such as svchost, etc).

      It’s basically highly advanced authentication/permissions but if you have a need to do this by API those resources should help give you an idea of what it will take (won’t be easy but is possible). You would need to use the official Google API reference and build the API commands as there are no PowerShell skeleton functions for us to draw from in UMN-Google to do this finely-grained level of authentication/impersonation/etc.

      Here’s the official Google resource on service account impersonation (the REST API is what we are technically using so choose the “REST” pane in the documentation and the code will look the most familiar / be most applicable to UMN-Google). At a rough glance it looks like you’d need to make some more permissions changes such as granting the ability to create impersonation tokens and rework the API commands to call for impersonation as the service account once the permissions are configured correctly to allow it.

      It’s not easy, but it’s possible and I linked to some Python tools earlier to apparently do it and I assume there are other ways as well outside of the official REST API. That’s definitely entering the highly advanced zone for sure and reaching the limits of my knowledge though. I really think the challenge is going to be the organizational permissions but hopefully all that helps!

  2. Avatar for michael

    Dude this is such a great guide. Thanks so much. Literally the only clear guide I could find online.

    Do you know how I can download a Gsheet as a csv from powershell? I’ve been trying the get-gsheetdata cmdlet but It’s erroring out for some reason.

    I’ve been running:
    Get-GSheetData -accessToken $accessToken -cell 'AllData' -sheetName 'Computers' -spreadSheetID $spreadSheetID

    My error is:
    Cannot index into a null array.
    At C:\Program Files\WindowsPowerShell\Modules\UMN-Google\1.2.6\UMN-Google.psm1:867 char:21
    + if ($sheet[0][$Column].Length -gt 0) {
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : InvalidOperation: (:) [], RuntimeException
    + FullyQualifiedErrorId : NullArray

    1. Avatar for James A. Chambers

      Hey Michael,

      Great question! I would try accessing it by range (A1 to the bottom right corner cell of your sheet) with something like:

      Get-GSheetData -accessToken $accessToken -rangeA1 "A1:C15" -sheetName "Computers" -spreadSheetID $SpreadsheetID -Debug -Verbose

      or

      Get-GSheetData -accessToken $accessToken -cell 'Range' -rangeA1 "A1:C15" -sheetName "Computers" -spreadSheetID $SpreadsheetID -Debug -Verbose

      Adjust C15 to be your bottom right cell. I assume this is the array it’s looking for and is worth a try!

  3. Avatar for Александ

    Добрый день столкунулся с проблемой

    Import-Module UMN-Google
    #
    #Set security protocol to TLS 1.2 to avoid TLS errors
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

    # Google API Authozation
    $scope = "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file"
    $certPath = "E:/iiko-326703-49e87ee65bfb.p12"
    #$certPath = "E:/iiko-326703-f9831d4faeda.json"
    $iss = 'iiko-719@iiko-326703.iam.gserviceaccount.com'
    $certPswd = 'notasecret'
    Using-Culture en-US {$accessToken = Get-GOAuthTokenService -scope $scope -certPath $certPath -certPswd $certPswd -iss $iss}
    try {
    $accessToken = Get-GOAuthTokenService -scope $scope -certPath $certPath -certPswd $certPswd -iss $iss
    } catch {
    $err = $_.Exception
    $err | Select-Object -Property *
    "Response: "
    $err.Response
    }

    Status : ProtocolError
    Response : System.Net.HttpWebResponse
    Message : Удаленный сервер возвратил ошибку: (400) Недопустимый запрос.
    Data : {}
    InnerException :
    TargetSite : System.Net.WebResponse GetResponse(System.Net.WebRequest)
    StackTrace : в Microsoft.PowerShell.Commands.WebRequestPSCmdlet.GetResponse(WebRequest request)
    в Microsoft.PowerShell.Commands.WebRequestPSCmdlet.ProcessRecord()
    HelpLink :
    Source : Microsoft.PowerShell.Commands.Utility
    HResult : -2146233079

    Response:
    IsMutuallyAuthenticated : False
    Cookies : {}
    Headers : {Vary, X-XSS-Protection, X-Frame-Options, X-Content-Type-Options...}
    SupportsHeaders : True
    ContentLength : -1
    ContentEncoding :
    ContentType : application/json; charset=UTF-8
    CharacterSet : UTF-8
    Server : scaffolding on HTTPServer2
    LastModified : 22.09.2021 9:51:52
    StatusCode : BadRequest
    StatusDescription : Bad Request
    ProtocolVersion : 1.1
    ResponseUri : https://www.googleapis.com/oauth2/v4/token
    Method : POST
    IsFromCache : False

    1. Avatar for James A. Chambers

      Hello Александ,

      Basically if you’re sure you set up your API key correctly it’s because English isn’t your default language.

      This has come up once or twice before though and there’s a relatively easy fix for it fortunately. It’s at the bottom of the article. We can do this with the Using-Culture function in the “(Optional) International Language Fix” section. Optional was definitely a poor choice of wording in retrospect because it will not be optional if English isn’t your default language (at least from what I can tell, I’ll adjust the article to make this more clear). Yes, it’s optional if you don’t need it but it makes no sense at all to describe it in that way and I was thinking about it backwards (from my perspective instead of the reader’s) when I wrote it. My mistake, I’ve improved and done that less and less over the years at least!

      This “Using-Culture” function will basically fix any differences between English and characters from other languages around the world from breaking the Google API. Can you give that a try so it adjusts the code for your locale and solves the non-English language errors/issues? I think you’re the first person using Russian that has tried it, but it happened to someone with French set as their language as well so it’s definitely a thing with this module for sure (or maybe it’s the Google API itself, I’m not entirely sure where in the process precisely that some non-English characters are getting misinterpreted and breaking things).

      At any rate luckily there are already built-in tools within PowerShell available that will handle these differences for us. I think the “Using-Culture” function will get yours taken care of as well!

      1. Avatar for Александр
        Александр

        Добрый день Команда выполняет Using-Culture en-US {$accessToken = Get-GOAuthTokenService -scope $scope -certPath $certPath -certPswd $certPswd -iss $iss} но ключ не получает как у вас может я что-то делаю не так

      2. Avatar for Александр
        Александр

        Помогло только смена на английский язык интерфейса но хотелось бы работать с русским

        1. Avatar for James A. Chambers

          Where did you change your language to get it working? Was that within PowerShell somehow? Or was that your entire computer? It might give us a hint here as to where the problem is. Are you able to get a token with the language changed/set but even with Using-Culture it still won’t get a token?

          I’m not totally sure what’s going on yet but I think we can figure it out. Let’s start by looking at the code behind getting a token:

          function Get-GOAuthTokenService {
          <# .Synopsis Get google auth 2.0 token for a service account .DESCRIPTION This is used in server-server OAuth token generation .PARAMETER certPath Local or network path to .p12 used to sign the JWT token .PARAMETER certPswd Password to access the private key in the .p12 .PARAMETER iss This is the Google Service account address .PARAMATER scope The API scopes to be included in the request. Space delimited, "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive" .EXAMPLE Get-GOAuthTokenService -scope "https://www.googleapis.com/auth/spreadsheets" -certPath "C:\users\$env:username\Desktop\googleSheets.p12" -certPswd 'notasecret' -iss "serviceAccount@googleProjectName.iam.gserviceaccount.com" #>
          [CmdletBinding()]
          Param
          (
          [Parameter(Mandatory)]
          [string]$certPath,

          [Parameter(Mandatory)]
          [string]$certPswd,

          [Parameter(Mandatory)]
          [string]$iss,

          [Parameter(Mandatory)]
          [string]$scope
          )
          Begin
          {
          # build JWT header
          $headerJSON = [Ordered]@{
          alg = "RS256"
          typ = "JWT"
          } | ConvertTo-Json -Compress
          $headerBase64 = ConvertTo-Base64URL -text $headerJSON
          }
          Process
          {
          # Build claims for JWT
          $now = (Get-Date).ToUniversalTime()
          $iat = [Math]::Floor([decimal](Get-Date($now) -UFormat "%s"))
          $exp = [Math]::Floor([decimal](Get-Date($now.AddMinutes(59)) -UFormat "%s"))
          $aud = "https://www.googleapis.com/oauth2/v4/token"
          $claimsJSON = [Ordered]@{
          iss = $iss
          scope = $scope
          aud = $aud
          exp = $exp
          iat = $iat
          } | ConvertTo-Json -Compress

          $claimsBase64 = ConvertTo-Base64URL -text $claimsJSON

          ################# Create JWT
          # Prep JWT certificate signing
          $googleCert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certPath, $certPswd,[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable )
          $rsaPrivate = $googleCert.PrivateKey
          $rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider
          $null = $rsa.ImportParameters($rsaPrivate.ExportParameters($true))

          # Signature is our base64urlencoded header and claims, delimited by a period.
          $toSign = [System.Text.Encoding]::UTF8.GetBytes($headerBase64 + "." + $claimsBase64)
          $signature = ConvertTo-Base64URL -Bytes $rsa.SignData($toSign,"SHA256") ## this needs to be converted back to regular text

          # Build request
          $jwt = $headerBase64 + "." + $claimsBase64 + "." + $signature
          $fields = 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion='+$jwt

          # Fetch token
          $response = Invoke-RestMethod -Uri "https://www.googleapis.com/oauth2/v4/token" -Method Post -Body $fields -ContentType "application/x-www-form-urlencoded"
          }
          End
          {
          return $response.access_token
          }
          }

          This is all that happens when you call Get-GOAuthTokenService behind the scenes. From what I can tell the Google API supports Russian and many other languages so I’m guessing the problem must be in UMN-Google. That means the issue is likely right in the code I just posted above.

          Every UMN-Google command to the Google API builds a URL and submits your data in that string. Here’s a sample:

          $fields = 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion='+$jwt

          What would happen if you put unescaped Russian characters into URLs like this? Are some of them interpreted as “breaks”? This seems like one possibility. It comes up all the time across lots of different computer systems for sure.

          Take a look at this post (it’s specific to php but the information is interesting).

          Use of utf8_decode was not enough for me by get page content from another site. Problem appear by different alphabet from standard latin. As example some chars (corresponding to HTML codes „ ,   and others) are converted to “?” or “xA0” (hex value). You need to make some conversion before execute utf8_decode. And you can not replace simple, that they can be part of 2 bytes code for a char (UTF-8 use 2 bytes). Next is for cyrillic alphabet, but for other must be very close.

          Obviously this comment is for PHP and we are talking about PowerShell but this is an example of the types of issues that can happen with language/culture related differences. This line caught my attention in particular:

          Problem appear by different alphabet from standard latin.

          Bingo. This has to explain the difference and why Using-Culture doesn’t seem to have been enough to fix yours but was enough to fix others that while using a different language are still using a Latin-based character set. The people who have tried this so far were still using the regular Latin character set and you would be using the Cyrillic if I’m not mistaken.

          My understanding is that Google Sheets does support Russian / Cyrillic characters. You can go to Google Sheets and type these in normally I’m sure otherwise you would not be bothering to attempt this. That’s the only thing I can think of that would explain why Using-Culture didn’t work for yours. It should have though theoretically. I found an example here from the Microsoft Developer Blog where someone was using it on Arabic!

          Looking at that did give me an idea though. It’s a total shot in the dark but I wonder if you try using Using-Culture with ru_RU will still fix this problem but allow you to switch back to Russian. My hopeful theory is that Using-Culture will still stop it from breaking the JSON requests but allow you to continue to use Russian. Can you try switching your Using-Culture from en_US to ru_RU? It might break it horribly but it’s worth a try if they set it up to handle cases like this. If it does break it horribly it would point more toward the problem being somewhere in the UMN-Google code I posted above.

          If that doesn’t get it I’m hoping that maybe you’ve encountered things like this before with other types of technology and that by me posting the underlying code building the JSON / explaining what is happening / where it’s likely going wrong you might have an idea to run with / try. It also might give you some ideas of how to search the non-English part of the internet where people may have shared some PowerShell tricks to fix issues with Cyrillic / Russian characters before (undoubtedly there’s stuff like this out there).

          Let’s hope we get lucky though and Using-Culture with ru_RU can fix this without making you use English!

          1. Avatar for Александр
            Александр

            PS C:\windows\system32> Using-Culture en_EN {$accessToken = Get-GOAuthTokenService -scope $scope -certPath $certPath -certPswd $certPswd -iss $iss}
            PS C:\windows\system32> echo $accessToken
            PS C:\windows\system32> Using-Culture ru_RU {$accessToken = Get-GOAuthTokenService -scope $scope -certPath $certPath -certPswd $certPswd -iss $iss}
            Invoke-RestMethod : Удаленный сервер возвратил ошибку: (400) Недопустимый запрос.
            C:\Program Files\WindowsPowerShell\Modules\UMN-Google\1.2.12\UMN-Google.psm1:196 знак:29
            + ... $response = Invoke-RestMethod -Uri "https://www.googleapis.com/oauth2 ...
            + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
            + CategoryInfo : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-RestMethod], WebException
            + FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand
            вот что происходит

            1. Avatar for James A. Chambers

              Hey Александр,

              Basically the source of the 400 error would be Google themselves. This was one of the closest things I could find to this happening. In that case (for a JDBC connection to Google API instead of PowerShell) they had to set the content encoding to UTF-8 and unicode. I’m guessing there’s some way to do this for PowerShell.

              Another one here for Ruby with the same solution.

              So the question now is how do we do this in PowerShell. I found this one which looks like we’re getting warmer.

              The only things I have for you to try will require you to modify/make your own modified Get-GOAuthTokenService. You could do something like this. People seem to write the REST request to a temporary file which will fix Invoke-RestRequest.

              Here’s another few ways you can try doing it.

              I would give you some code to try but I can’t reproduce this issue on my end since my system isn’t configured this way. It might be as simple as changing the Invoke-RestMethod line like was suggested to:

              For the Body parameter try this:

              ... -Body ([System.Text.Encoding]::UTF8.GetBytes($jsondata))
              The string in PowerShell is Unicode but you've specified a UTF8 encoding so I think you need to give it some help getting to UTF8.

              That would be great if it was this easy but the writing it to a file and then reading it back trick seems to be much more widely acknowledged as working so that seems like the most promising method.

              This is a known issue with Invoke-RestMethod. I’ve been able to find enough examples / documentation of this happening to say that for sure now. Russian and Unicode specifically keep coming up specifically in many of these threads which is a further sign we are on the right track.

              We need to convert the request to a format it likes for the web server. It sounds like a simple encoding issue (from Unicode from what I’m seeing) which writing it to a file temporarily and then rereading it in the correct format it wants seems to resolve it from all these examples (while allowing you to continue to use Russian!).

              This all makes sense because we know when you set it all to English it worked so it can’t be anything with your certificate, your permissions, your cert’s format on the drive, all of those are already ruled out at this point. It has to be something like the encoding which is known to change between different languages / character sets / etc. and we’ve basically already determined that with the language test you did.

              I would almost just recommend “forking” UMN-Google’s source code, or you could just make your own function to replace Get-GOAuthTokenService like Get-CustomToken and then copy the code from UMN-Google to make our modifications. If it works you’ll probably need to modify any function you want to use, but if it works with 1 and we can get a token then we know it would be worth converting the other types of requests. I checked and UMN-Google does *NOT* change the encoding for you:

              Invoke-RestMethod -Method $method -Uri $uri -Body $json -ContentType "application/json" -Headers @{"Authorization"="Bearer $accessToken"}
              would theoretically become:
              Invoke-RestMethod -Method $method -Uri $uri -Body ([System.Text.Encoding]::UTF8.GetBytes($json)) -ContentType "application/json" -Headers @{"Authorization"="Bearer $accessToken"}

              would be one to try. Same thing for Get-GOAuthTokenService:

              $response = Invoke-RestMethod -Uri "https://www.googleapis.com/oauth2/v4/token" -Method Post -Body $fields -ContentType "application/x-www-form-urlencoded"
              to
              $response = Invoke-RestMethod -Uri "https://www.googleapis.com/oauth2/v4/token" -Method Post -Body ([System.Text.Encoding]::UTF8.GetBytes($fields)) -ContentType "application/x-www-form-urlencoded"

              Using the file trick would look like:

              $response = Invoke-RestMethod -Uri "https://www.googleapis.com/oauth2/v4/token" -Method Post -Body $fields -ContentType "application/x-www-form-urlencoded"
              to
              Invoke-RestMethod -Uri "https://www.googleapis.com/oauth2/v4/token" -Method Post -Body $fields -ContentType "application/x-www-form-urlencoded" -OutFile c:\temp\file.txt
              [xml]$response = Get-Content -Path c:\temp\file.txt -Encoding UTF8

              Can you give this a try? It looks like there’s a few different ways you can try this that I linked to. I’m not sure which one/ones will be the working version for you so literally I would try modifying the Invoke-RestMethod call in all the different ways people have tried in these different links/threads but I think this is getting pretty close to the answer at least!

    1. Avatar for James A. Chambers

      Hey Mike,

      Great question! The easiest way (and the way I always used to be honest) is to add permissions for yourself to edit the spreadsheet on the computer. Once you make the changes (like bolding the headers, setting the column sizes, anything visual) when you update the columns the formatting changes will still stay in place.

      UMN-Google doesn’t have any built in way to do this. Can it be done programmatically though? Yes it can. You would just need to make the requests yourself by modifying the UMN-Google request template code by referencing the Google API (I will demonstrate where to go / how to do this).

      If you have the need to do it programatically check this out first keeping in mind we’ll be using it as a reference to build some commands and you don’t need to read it all line for line. This is the Google API developer’s documentation on using formatting in Google Sheets. Don’t be too overwhelmed yet until our next step.

      Next I would recommend just copying UMN-Google’s code into your own module and adding some new methods to add style. You could also just completely ditch UMN-Google and handle everything in your own code (it doesn’t do too much).
      You can refer to UMN-Google to see the structure of how to do this here.

      Ok, now that you’ve seen both the UMN-Google functions and structure and how to do the formatting you can put these together to make a new request. Let’s look at the code for Clear-GSheetSheet from UMN-Google as an example:

      [CmdletBinding()]
      Param
      (
      [Parameter(Mandatory)]
      [string]$accessToken,

      [Parameter(Mandatory)]
      [string]$sheetName,

      [Parameter(Mandatory)]
      [string]$spreadSheetID

      )

      Begin{}
      Process
      {
      $sheetID = Get-GSheetSheetID -accessToken $accessToken -spreadSheetID $spreadSheetID -sheetName $sheetName
      $properties = @{requests=@(@{updateCells=@{range=@{sheetId=$sheetID};fields="userEnteredValue"}})} |ConvertTo-Json -Depth 10
      $suffix = "$spreadSheetID" + ":batchUpdate"
      $uri = "https://sheets.googleapis.com/v4/spreadsheets/$suffix"
      Invoke-RestMethod -Method Post -Uri $uri -Body $properties -ContentType 'application/json' -Headers @{"Authorization"="Bearer $accessToken"}
      }
      End{}
      }

      Now do you see the properties above embedded in the JSON? Those are what you would add/adjust to based on the first Google API documentation document I linked to so that the parameters and properties matches what Google expects. It would theoretically be pretty easy to modify one of these existing “batchUpdate” commands and add in the color/style formatting. Here’s a brief example from the same API page:

      "requests": [
      {
      "repeatCell": {
      "range": {
      "sheetId": sheetId,
      "startRowIndex": 0,
      "endRowIndex": 10,
      "startColumnIndex": 0,
      "endColumnIndex": 1
      },
      "cell": {
      "userEnteredFormat": {
      "numberFormat": {
      "type": "DATE",
      "pattern": "hh:mm:ss am/pm, ddd mmm dd yyyy"
      }
      }
      },
      "fields": "userEnteredFormat.numberFormat"
      }

      You could modify your own requests with this format and other formats from the same Google API developer’s documentation I linked to (tons and tons of different options are available). The example code doesn’t add any of these special extra JSON properties when we build the ArrayList and pretty much just submits pure data but additional properties are available in the JSON that aren’t being used.

      This option will either be relatively easy (you’ll just be adapting/running with the existing UMN-Google framework and modifying the parameters to include your styling code) or very difficult depending on your experience level (it’s going to be hard to adapt it in the way I’m saying if it all looks like an alien language) and will take a decent amount of time either way but it’s definitely possible if you have an absolute requirement.

      Unfortunately this isn’t already built into UMN-Google but the option is still available to just preformat your documents in the browser (just give yourself access by adding access to your email) but the groundwork has definitely been laid to expand upon pretty easily and send some custom formatting code along with your spreadsheet data to the Google API (it will basically be in JSON requests just like your current data is now but as additional properties). Hopefully that helps!

      1. Avatar for Mike Son

        Hey James,
        Thanks for your quick reply that is really good idea I will have play around with and see what I can do 🙂 , I am really enjoying your blog.
        Keep up the great work!

        1. Avatar for James A. Chambers

          Hey Mike,

          No problem at all, let me know if you have any more questions / run into any trouble! I’d be happy to take a look if you get any code going etc. as I am not sure I’ve heard of anyone else doing this yet (at least not that they posted back here about). It’s absolutely possible though and all part of the same API we are already using a lot of via UMN-Google.

          Take care!

  4. Avatar for Jeremy Sonnett

    This is amazing! Though in the final code you are setting the $import variable twice, so I wasn’t getting any rows until I commented out the second $import = New-Object System.Collections.ArrayList($null)

  5. Avatar for AndyO

    Hello James,

    Firstly, Thank you! This is so useful….
    Secondly and probably most obviously, I need a little help
    I have created a CSV file by using PowerShell to query my database and I was hoping to use your script to upload the CSV to GoogleSheets
    The CSV file holds headers and data, always 21 columns and x rows.
    I am having a nightmare actually getting the CSV imported, although headers are fine

    VERBOSE: PUT https://sheets.googleapis.com/v4/spreadsheets//values/Main!A1:U1?valueInputOp
    tion=RAW with -1-byte payload
    VERBOSE: received -1-byte response of content type application/json; charset=UTF-8

    Could you provide any help please?

    1. Avatar for James A. Chambers

      Hey AndyO,

      Absolutely! I think the problem is with your range. The range should basically be the top left corner (A1) down to the bottom right corner (UX) but I see (U1) in your range so it may not be counting your rows correctly (I use a fancy row counting trick in my code to do this for the example) and if they’re always the same you could probably put a static # in there. A1 to U1 would only be the top row being posted and that won’t match the size of your ArrayList which almost certainly has x number of rows and not just a single row. This will trigger a (vague but much less vague than PowerShell) error in the Google API console.

      Now look at this output from mine here:

      PS Y:\James\PowerShell\GoogleSheets> y:\James\PowerShell\GoogleSheets\Test-Sheets.ps1
      VERBOSE: PUT https://sheets.googleapis.com/v4/spreadsheets/1anI7Bdr0ZjkSDwMbGpCb0KZc1SpSC-3JdQiycvoM5CM/values/Computers!A1:C3?valueInputOption=RAW with -1-byte payload
      VERBOSE: received -1-byte response of content type application/json; charset=UTF-8

      spreadsheetId : 1anI7Bdr0ZjkSDwMbGpCb0KZc1SpSC-3JdQiycvoM5CM
      updatedRange : Computers!A1:C3
      updatedRows : 3
      updatedColumns : 3
      updatedCells : 9

      There’s virtually nothing that can go wrong other than giving it the wrong credentials / wrong range. I would try setting your last range value to the number of rows like U(X) which in the examples I actually calculate this (if you have this calculation still in there it may be getting the wrong count somehow since it appears to only be picking “1” instead of x rows). You also will want to log into the Google API console because you will get no errors from PowerShell (other than 400 failed basically with no reason given). You have to go to the console to see them (almost certainly invalid range errors).

      Look at the code behind UMN-Google to understand why and how little is actually happening here:

      function Set-GSheetData
      {
      <# .Synopsis Set values in sheet in specific cell locations or append data to a sheet .DESCRIPTION Set json data values on a sheet in specific cell locations or append data to a sheet .PARAMETER accessToken access token used for authentication. Get from Get-GOAuthTokenUser or Get-GOAuthTokenService .PARAMETER append Switch option to append data. See rangeA1 if not appending .PARAMETER rangeA1 Range in A1 notation https://msdn.microsoft.com/en-us/library/bb211395(v=office.12).aspx . The dimensions of the $values you put in MUST fit within this range .PARAMETER sheetName Name of sheet to set data in .PARAMETER spreadSheetID ID for the target Spreadsheet. This is returned when a new sheet is created or use Get-GSheetSpreadSheetID .PARAMETER valueInputOption Default to RAW. Optionally, you can specify if you want it processed as a formula and so forth. .PARAMETER values The values to write to the sheet. This should be an array list. Each list array represents one ROW on the sheet. .EXAMPLE Set-GSheetData -accessToken $accessToken -rangeA1 'A1:B2' -sheetName 'My Sheet' -spreadSheetID $spreadSheetID -values @(@("a","b"),@("c","D")) .EXAMPLE Set-GSheetData -accessToken $accessToken -append 'Append'-sheetName 'My Sheet' -spreadSheetID $spreadSheetID -values $arrayValues #>
      [CmdletBinding()]
      Param
      (
      [Parameter(Mandatory)]
      [string]$accessToken,

      [Parameter(ParameterSetName='Append')]
      [switch]$append,

      [Parameter(ParameterSetName='set')]
      [string]$rangeA1,

      [Parameter(Mandatory)]
      [string]$sheetName,

      [Parameter(Mandatory)]
      [string]$spreadSheetID,

      [string]$valueInputOption = 'RAW',

      [Parameter(Mandatory)]
      [System.Collections.ArrayList]$values
      )

      Begin
      {
      if ($append)
      {
      $method = 'POST'
      $uri = "https://sheets.googleapis.com/v4/spreadsheets/$spreadSheetID/values/$sheetName"+":append?valueInputOption=$valueInputOption"
      }
      else
      {
      $method = 'PUT'
      $uri = "https://sheets.googleapis.com/v4/spreadsheets/$spreadSheetID/values/$sheetName!$rangeA1"+"?valueInputOption=$valueInputOption"
      }
      }

      Process
      {
      $json = @{values=$values} | ConvertTo-Json
      Invoke-RestMethod -Method $method -Uri $uri -Body $json -ContentType "application/json" -Headers @{"Authorization"="Bearer $accessToken"}
      }

      End{}
      }

      That’s it. That’s all that is in UMN-Google. You could actually implement that one line (Invoke-RestMethod) and completely replace UMN-Google altogether. It does nearly nothing other than put your parameters into Google otherwise you are in control 100% (and you could easily make it actually 100% by just building the URI / json yourself in a couple of prep lines like the UMN-Google wrapper and calling Invoke-RestMethod).

      Once I looked at the UMN-Google source code and understood exactly what it was doing and that it was basically just a single pre-built REST template it got a lot easier to develop sheets with it and gave me a lot more confidence knowing that somehow my command / ArrayList wasn’t built correctly etc. and that UMN-Google wasn’t doing anything wrong or overly complex behind the scenes. It also helped me understand the Google API model and that with this API almost nothing is actually happening in PowerShell and you could open up your web browser / a test tool like postman and feed this command in and it would still work if you had the right token. This is a very thin communication/messaging layer and all the “action” is happening inside Google API.

      UMN-Google is mostly just a convenience wrapper so you don’t have to manually build all the REST URIs and JSON and on top of that know all the Google parameters each command has to take etc. but behind the scenes it’s just passing what you give it to Google through these preassembled REST URIs.

      It’s almost always going to be range issues / input errors (with the occasional trying to set a column that doesn’t exist yet or a curveball like that / restriction you may have not been aware of, all the more reason to start using the Google API console as you’ll never guess it in a million years without it if you get one of the weird ones). The only exception has been the person who doesn’t have English has their computer’s default language and they posted a fix for that. Otherwise when I say there is nothing to UMN-Google I literally mean there’s nothing to it. A single line that posts whatever you give it to Google!

      The console is available here then click “View Metrics”. It’s all just inputting the right # of rows/columns/etc. and building the ArrayList and you’re going to need to use the console to troubleshoot this. Otherwise you’re blind and just guessing without the console. Tricky for sure, but the console will tell you what is going wrong!

      It’s just a one liner though that posts the data so it’s going to be fundamentals like the range for sure because the code is just a one liner that sends your parameters to the Google API and it’s either a valid request or it isn’t basically as far as PowerShell is concerned and only Google will be able to tell you more about why they think your request was invalid and they rejected it. Hopefully that gets it for you!

Leave a Comment

Your email address will not be published. Required fields are marked *

Type here..

Exit mobile version