P07: REST API Client Module
P07: REST API Client Module
Project Overview
What you’ll build: A PowerShell module that wraps a REST API (GitHub, Jira, or any API you use) with proper cmdlets–Get-GitHubRepo, New-GitHubIssue, etc.
| Attribute | Value |
|---|---|
| Difficulty | Level 2: Intermediate |
| Time Estimate | 1-2 weeks |
| Programming Language | PowerShell |
| Alternative Languages | Python, TypeScript, Go |
| Knowledge Area | REST APIs, Module Development |
| Prerequisites | Basic PowerShell, understanding of HTTP/REST |
| Main Book | PowerShell in Depth by Don Jones |
Learning Objectives
After completing this project, you will be able to:
- Create PowerShell modules with proper architecture - Understand the
.psd1manifest,.psm1root module, and function organization patterns that make modules installable and maintainable - Work with REST APIs using Invoke-RestMethod - Master HTTP methods, headers, request bodies, and response handling in PowerShell
- Implement multiple authentication patterns - Handle API keys, OAuth 2.0 tokens, Basic auth, and secure credential storage using SecureString
- Transform API responses into rich PowerShell objects - Convert JSON responses into typed PSCustomObjects with meaningful property names
- Handle pagination automatically - Implement generators or iterators that transparently handle multi-page API responses
- Design pipeline-friendly cmdlets - Accept pipeline input and emit objects that chain naturally with other PowerShell commands
- Implement rate limiting and retry logic - Handle API throttling gracefully with exponential backoff
- Write production-quality code - Include proper error handling, verbose output, and parameter validation
Deep Theoretical Foundation
REST API Fundamentals
REST (Representational State Transfer) is an architectural style that defines how web services should behave. Understanding REST deeply is essential before wrapping an API.
The HTTP Request/Response Cycle:
+-------------------------------------------------------------------+
| REST API Request Flow |
+-------------------------------------------------------------------+
[PowerShell Client] [REST API Server]
| |
| 1. Build Request |
| - Choose HTTP Method (GET/POST/etc) |
| - Construct URL with path + query params |
| - Add headers (Auth, Accept, User-Agent) |
| - Serialize body to JSON (for POST/PUT) |
| |
| -------- HTTP Request -----------------------> |
| GET /repos/microsoft/vscode HTTP/1.1 |
| Host: api.github.com |
| Authorization: Bearer ghp_xxxx |
| Accept: application/json |
| |
| |
| 2. Server Processing |
| - Validate auth |
| - Parse request |
| - Execute operation |
| - Build response |
| |
| <------- HTTP Response ----------------------- |
| HTTP/1.1 200 OK |
| Content-Type: application/json |
| X-RateLimit-Remaining: 4999 |
| |
| {"id": 41881900, "name": "vscode", ...} |
| |
| 3. Parse Response |
| - Check status code |
| - Deserialize JSON to object |
| - Transform to PSCustomObject |
| - Emit to pipeline |
| |
+-------------------------------------------------------------------+
HTTP Methods and Their Semantics:
| Method | Purpose | Idempotent | Safe | Request Body | Typical Response |
|---|---|---|---|---|---|
| GET | Retrieve resource(s) | Yes | Yes | No | Resource data |
| POST | Create new resource | No | No | Yes | Created resource |
| PUT | Replace resource entirely | Yes | No | Yes | Updated resource |
| PATCH | Partial update | No | No | Yes | Updated resource |
| DELETE | Remove resource | Yes | No | No | Empty or confirmation |
| HEAD | Get headers only | Yes | Yes | No | Headers only |
| OPTIONS | Get allowed methods | Yes | Yes | No | Allowed methods |
Idempotent means calling it multiple times has the same effect as calling it once. Safe means it doesn’t modify server state.
HTTP Status Codes You Must Handle:
+-------------------------------------------------------------------+
| Status Code Categories |
+-------------------------------------------------------------------+
2xx - Success
200 OK - Request succeeded, response has body
201 Created - Resource created (POST), check Location header
204 No Content - Success but no body (common for DELETE)
3xx - Redirection
301 Moved Perm - Resource URL changed permanently
302 Found - Temporary redirect
304 Not Modified - Cached version is current (ETags)
4xx - Client Errors (YOUR fault)
400 Bad Request - Malformed request syntax
401 Unauthorized - Missing or invalid authentication
403 Forbidden - Valid auth but insufficient permissions
404 Not Found - Resource doesn't exist
409 Conflict - Resource state conflict (already exists)
422 Unprocessable- Semantic errors in request body
429 Too Many Req - Rate limited, check Retry-After header
5xx - Server Errors (THEIR fault)
500 Internal - Server crashed
502 Bad Gateway - Upstream service failed
503 Unavailable - Server overloaded/maintenance
504 Timeout - Upstream service timeout
+-------------------------------------------------------------------+
JSON: The Universal Data Format
JSON (JavaScript Object Notation) is the standard for REST API data exchange. PowerShell handles JSON natively:
# JSON to PowerShell object
$json = '{"name": "vscode", "stars": 150000}'
$obj = $json | ConvertFrom-Json
$obj.name # "vscode"
# PowerShell object to JSON
$data = @{
title = "Bug Report"
body = "Steps to reproduce..."
labels = @("bug", "high-priority")
}
$json = $data | ConvertTo-Json -Depth 10 # Depth matters for nested objects!
Why Depth Matters:
# Default depth is 2 - nested objects get truncated!
@{a = @{b = @{c = @{d = "deep"}}}} | ConvertTo-Json
# Output: {"a": {"b": {"c": "System.Collections.Hashtable"}}} # WRONG!
@{a = @{b = @{c = @{d = "deep"}}}} | ConvertTo-Json -Depth 10
# Output: {"a": {"b": {"c": {"d": "deep"}}}} # Correct
PowerShell Module Architecture
A PowerShell module is a package of related functions, variables, and resources. Understanding module architecture is critical for building professional tools.
Module Types:
| Type | Extension | Description | Use Case |
|---|---|---|---|
| Script Module | .psm1 |
Contains PowerShell script | Most common, our focus |
| Binary Module | .dll |
Compiled .NET assembly | High performance, complex logic |
| Manifest Module | .psd1 only |
Points to other modules | Aggregation of modules |
| Dynamic Module | In-memory | Created at runtime | Testing, temporary functions |
Script Module File Structure:
GitHubPS/
+-- GitHubPS.psd1 # Module manifest (metadata)
+-- GitHubPS.psm1 # Root module (entry point)
+-- Public/ # Exported functions (user-facing)
| +-- Get-GitHubRepo.ps1
| +-- Get-GitHubIssue.ps1
| +-- New-GitHubIssue.ps1
| +-- Set-GitHubIssue.ps1
| +-- Remove-GitHubIssue.ps1
| +-- Set-GitHubAuthentication.ps1
| +-- Get-GitHubUser.ps1
+-- Private/ # Internal functions (not exported)
| +-- Invoke-GitHubApi.ps1
| +-- Get-GitHubPaginated.ps1
| +-- ConvertTo-GitHubObject.ps1
| +-- Test-GitHubRateLimit.ps1
+-- Classes/ # PowerShell classes (optional)
| +-- GitHubException.ps1
+-- Types/ # Type definitions (optional)
| +-- GitHubPS.Types.ps1xml
+-- Formats/ # Output formatting (optional)
| +-- GitHubPS.Format.ps1xml
+-- Tests/ # Pester tests
| +-- Get-GitHubRepo.Tests.ps1
| +-- Invoke-GitHubApi.Tests.ps1
+-- en-US/ # Help files (optional)
+-- about_GitHubPS.help.txt
The Manifest File (.psd1) - Your Module’s Identity:
# GitHubPS.psd1
@{
# Module identity
RootModule = 'GitHubPS.psm1'
ModuleVersion = '1.0.0'
GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' # Generate with [guid]::NewGuid()
Author = 'Your Name'
CompanyName = 'Your Company'
Copyright = '(c) 2025 Your Name. All rights reserved.'
Description = 'PowerShell module for interacting with the GitHub REST API'
# Requirements
PowerShellVersion = '5.1'
# RequiredModules = @('SomeOtherModule')
# RequiredAssemblies = @('Some.dll')
# Exports - CRITICAL for security and performance
FunctionsToExport = @(
'Get-GitHubRepo',
'Get-GitHubIssue',
'New-GitHubIssue',
'Set-GitHubIssue',
'Remove-GitHubIssue',
'Set-GitHubAuthentication',
'Get-GitHubUser'
)
CmdletsToExport = @() # Empty = export none
VariablesToExport = @() # Empty = export none
AliasesToExport = @() # Empty = export none
# Private data for module use
PrivateData = @{
PSData = @{
Tags = @('GitHub', 'API', 'REST', 'Git')
LicenseUri = 'https://github.com/you/GitHubPS/blob/main/LICENSE'
ProjectUri = 'https://github.com/you/GitHubPS'
ReleaseNotes = 'Initial release'
}
}
}
The Root Module (.psm1) - Loading and Initialization:
# GitHubPS.psm1
#Requires -Version 5.1
# Module-scoped variables (persist across function calls within session)
$script:GitHubToken = $null
$script:GitHubBaseUrl = 'https://api.github.com'
$script:GitHubApiVersion = '2022-11-28'
# Dot-source all function files
$Public = @(Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1" -ErrorAction SilentlyContinue)
$Private = @(Get-ChildItem -Path "$PSScriptRoot\Private\*.ps1" -ErrorAction SilentlyContinue)
foreach ($file in @($Public + $Private)) {
try {
. $file.FullName
Write-Verbose "Imported function: $($file.BaseName)"
}
catch {
Write-Error "Failed to import function $($file.FullName): $_"
}
}
# Export only public functions (manifest also controls this, but explicit is better)
Export-ModuleMember -Function $Public.BaseName
# Module initialization - runs when module is imported
$configPath = "$HOME\.github-ps\token"
if (Test-Path $configPath) {
try {
$encrypted = Get-Content $configPath
$script:GitHubToken = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR(
($encrypted | ConvertTo-SecureString)
)
)
Write-Verbose "Loaded saved GitHub token"
}
catch {
Write-Warning "Could not load saved token. Use Set-GitHubAuthentication to authenticate."
}
}
Module Loading Locations:
# View your module paths
$env:PSModulePath -split [IO.Path]::PathSeparator
# Typical paths (Windows):
# C:\Users\<you>\Documents\PowerShell\Modules # User modules (PowerShell 7)
# C:\Users\<you>\Documents\WindowsPowerShell\Modules # User modules (PS 5.1)
# C:\Program Files\PowerShell\Modules # All users (PS 7)
# C:\Program Files\WindowsPowerShell\Modules # All users (PS 5.1)
# C:\Windows\System32\WindowsPowerShell\v1.0\Modules # System modules
# Install your module for current user:
$modulePath = "$HOME\Documents\PowerShell\Modules\GitHubPS"
Copy-Item -Path .\GitHubPS -Destination $modulePath -Recurse
# Or use symlink for development:
New-Item -ItemType SymbolicLink -Path $modulePath -Target (Resolve-Path .\GitHubPS)
Authentication Patterns
Authentication is the most critical aspect of API clients. You must handle credentials securely.
Pattern 1: API Key in Header (Bearer Token)
This is the most common pattern for modern APIs like GitHub.
# The token goes in the Authorization header
$headers = @{
'Authorization' = "Bearer $token" # or "token $token" for GitHub
'Accept' = 'application/vnd.github+json'
'X-GitHub-Api-Version' = '2022-11-28'
}
Invoke-RestMethod -Uri $url -Headers $headers
Pattern 2: API Key as Query Parameter
Some older APIs use this pattern (less secure - appears in logs).
$url = "https://api.example.com/data?api_key=$apiKey&other_param=value"
Invoke-RestMethod -Uri $url
Pattern 3: Basic Authentication
Username and password encoded in Base64.
$pair = "$($username):$($password)"
$bytes = [System.Text.Encoding]::ASCII.GetBytes($pair)
$base64 = [System.Convert]::ToBase64String($bytes)
$headers = @{
'Authorization' = "Basic $base64"
}
# Or let PowerShell handle it:
$credential = Get-Credential
Invoke-RestMethod -Uri $url -Authentication Basic -Credential $credential
Pattern 4: OAuth 2.0 Flow
The most complex but most secure pattern for user-facing apps.
+-------------------------------------------------------------------+
| OAuth 2.0 Authorization Code Flow |
+-------------------------------------------------------------------+
[User] [Your App] [GitHub] [API]
| | | |
| 1. User wants to | | |
| connect GitHub | | |
| ------------------> | | |
| | | |
| | 2. Redirect to | |
| | GitHub login | |
| | -------------------> | |
| | | |
| <----------------------------------------- | |
| 3. User sees GitHub login page | |
| | |
| -----------------------------------------> | |
| 4. User enters credentials, approves | |
| | |
| <----------------------------------------- | |
| 5. Redirect back to your app with code | |
| yourapp.com/callback?code=abc123 | |
| | | |
| | 6. Exchange code | |
| | for access token | |
| | -------------------> | |
| | | |
| | <------------------- | |
| | 7. Access token | |
| | + refresh token | |
| | | |
| | 8. API calls with | |
| | access token | |
| | ---------------------------------------->|
| | | |
| | <----------------------------------------|
| | 9. API response | |
| | | |
| <------------------ | | |
| 10. Data displayed | | |
+-------------------------------------------------------------------+
Secure Credential Storage:
NEVER store credentials in plain text. Use PowerShell’s SecureString and DPAPI.
# Encrypt and save (only decryptable by same user on same machine)
function Save-GitHubToken {
param([string]$Token)
$secureToken = ConvertTo-SecureString $Token -AsPlainText -Force
$encrypted = $secureToken | ConvertFrom-SecureString
$configDir = "$HOME\.github-ps"
if (-not (Test-Path $configDir)) {
New-Item -ItemType Directory -Path $configDir -Force | Out-Null
}
$encrypted | Out-File "$configDir\token" -Force
# Secure the file
$acl = Get-Acl "$configDir\token"
$acl.SetAccessRuleProtection($true, $false) # Disable inheritance
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
$env:USERNAME, "FullControl", "Allow"
)
$acl.SetAccessRule($rule)
Set-Acl "$configDir\token" $acl
}
# Load and decrypt
function Get-SavedGitHubToken {
$configPath = "$HOME\.github-ps\token"
if (-not (Test-Path $configPath)) {
return $null
}
try {
$encrypted = Get-Content $configPath
$secureToken = $encrypted | ConvertTo-SecureString
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
return [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
}
finally {
# Clear sensitive data from memory
if ($bstr) {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
}
}
Invoke-RestMethod vs Invoke-WebRequest
PowerShell offers two cmdlets for HTTP requests. Understanding when to use each is critical.
| Aspect | Invoke-RestMethod | Invoke-WebRequest |
|---|---|---|
| Returns | Parsed content (object) | Full response object |
| JSON handling | Auto-parses to PSObject | Raw JSON string in Content |
| Headers access | Not accessible | Full access via .Headers |
| Status code | Only on error | Always in .StatusCode |
| Cookies | Not accessible | Full access via .Cookies |
| Binary data | Limited | Better support |
| Performance | Slightly faster | Slightly slower |
When to use Invoke-RestMethod:
- Simple API calls where you just need the data
- When you don’t need response headers
- For quick scripts and prototyping
When to use Invoke-WebRequest:
- When you need response headers (pagination links, rate limits)
- When you need the status code for logic
- For binary downloads
- When debugging API issues
# Invoke-RestMethod - simpler, returns data directly
$repos = Invoke-RestMethod -Uri "https://api.github.com/users/octocat/repos"
$repos[0].name # Works immediately
# Invoke-WebRequest - more control
$response = Invoke-WebRequest -Uri "https://api.github.com/users/octocat/repos"
$response.StatusCode # 200
$response.Headers # @{X-RateLimit-Remaining=4999; ...}
$repos = $response.Content | ConvertFrom-Json
# For pagination, you NEED Invoke-WebRequest to access Link headers
$response = Invoke-WebRequest -Uri $url -Headers $headers
$linkHeader = $response.Headers['Link']
# Link: <https://api.github.com/...?page=2>; rel="next", <...?page=34>; rel="last"
Pagination Handling
Most APIs limit results per request. Handling pagination transparently is what separates amateur from professional modules.
Common Pagination Patterns:
+-------------------------------------------------------------------+
| API Pagination Patterns |
+-------------------------------------------------------------------+
1. PAGE-BASED (GitHub, many others)
Request: GET /repos?page=2&per_page=100
Response: Items + Link header with next/prev/last URLs
Link: <https://api.github.com/repos?page=3>; rel="next",
<https://api.github.com/repos?page=1>; rel="prev",
<https://api.github.com/repos?page=10>; rel="last"
2. OFFSET-BASED (Jira, some others)
Request: GET /issues?startAt=100&maxResults=50
Response: Items + total count
{"startAt": 100, "maxResults": 50, "total": 500, "issues": [...]}
3. CURSOR-BASED (Twitter, Slack, modern APIs)
Request: GET /messages?cursor=abc123
Response: Items + next cursor
{"messages": [...], "next_cursor": "xyz789", "has_more": true}
4. TOKEN-BASED (Google APIs)
Request: GET /files?pageToken=abc123
Response: Items + next page token
{"files": [...], "nextPageToken": "xyz789"}
+-------------------------------------------------------------------+
Implementing Pagination for GitHub (Link Header):
function Get-GitHubPaginated {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Endpoint,
[hashtable]$QueryParameters = @{},
[int]$MaxResults = [int]::MaxValue,
[int]$PerPage = 100
)
# Ensure per_page is set
$QueryParameters['per_page'] = [Math]::Min($PerPage, $MaxResults)
$results = @()
$url = Build-GitHubUrl -Endpoint $Endpoint -QueryParameters $QueryParameters
do {
Write-Verbose "Fetching: $url"
# Must use Invoke-WebRequest to access Link header
$response = Invoke-WebRequest -Uri $url -Headers (Get-GitHubHeaders) -ErrorAction Stop
# Parse response body
$items = $response.Content | ConvertFrom-Json
if ($items -is [Array]) {
$results += $items
}
else {
$results += @($items)
}
Write-Verbose "Retrieved $($items.Count) items (total: $($results.Count))"
# Check if we have enough
if ($results.Count -ge $MaxResults) {
break
}
# Parse Link header for next page
$url = $null
$linkHeader = $response.Headers['Link']
if ($linkHeader) {
# Parse: <url>; rel="next", <url>; rel="last"
$links = $linkHeader -split ','
foreach ($link in $links) {
if ($link -match '<([^>]+)>;\s*rel="next"') {
$url = $Matches[1]
break
}
}
}
} while ($url)
# Return limited results
return $results | Select-Object -First $MaxResults
}
Custom Object Creation and Type Extensions
Transforming raw API responses into rich PowerShell objects makes your module feel native.
Creating PSCustomObjects:
function ConvertTo-GitHubRepo {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[object]$InputObject
)
process {
[PSCustomObject]@{
# Type name for formatting and identification
PSTypeName = 'GitHub.Repository'
# Curated properties with friendly names
Id = $InputObject.id
Name = $InputObject.name
FullName = $InputObject.full_name
Description = $InputObject.description
Url = $InputObject.html_url
CloneUrl = $InputObject.clone_url
SshUrl = $InputObject.ssh_url
# Renamed for PowerShell conventions
Stars = $InputObject.stargazers_count
Forks = $InputObject.forks_count
OpenIssues = $InputObject.open_issues_count
Watchers = $InputObject.watchers_count
# Categorization
Language = $InputObject.language
Topics = $InputObject.topics -join ', '
# Booleans
IsPrivate = $InputObject.private
IsArchived = $InputObject.archived
IsFork = $InputObject.fork
HasIssues = $InputObject.has_issues
HasWiki = $InputObject.has_wiki
# Dates (converted to DateTime)
CreatedAt = [DateTime]$InputObject.created_at
UpdatedAt = [DateTime]$InputObject.updated_at
PushedAt = if ($InputObject.pushed_at) { [DateTime]$InputObject.pushed_at } else { $null }
# Owner info (nested object)
Owner = $InputObject.owner.login
OwnerType = $InputObject.owner.type
# License
License = $InputObject.license.spdx_id
# Keep original for advanced use
_Raw = $InputObject
}
}
}
Type Extensions (Types.ps1xml):
Add methods and properties to your custom types:
<?xml version="1.0" encoding="utf-8"?>
<Types>
<Type>
<Name>GitHub.Repository</Name>
<Members>
<!-- Script property: computed on access -->
<ScriptProperty>
<Name>PopularityScore</Name>
<GetScriptBlock>
$this.Stars + ($this.Forks * 2) + ($this.Watchers * 0.5)
</GetScriptBlock>
</ScriptProperty>
<!-- Script method -->
<ScriptMethod>
<Name>GetIssues</Name>
<Script>
param([string]$State = 'open')
Get-GitHubIssue -Owner $this.Owner -Repo $this.Name -State $State
</Script>
</ScriptMethod>
<!-- Alias property -->
<AliasProperty>
<Name>StarCount</Name>
<ReferencedMemberName>Stars</ReferencedMemberName>
</AliasProperty>
</Members>
</Type>
</Types>
Load types in your module:
# In GitHubPS.psm1
Update-TypeData -PrependPath "$PSScriptRoot\Types\GitHubPS.Types.ps1xml"
Complete Project Specification
Functional Requirements
| ID | Requirement | Priority | Description |
|---|---|---|---|
| F1 | Module structure with manifest | Must Have | Proper .psd1/.psm1 structure with Public/Private folders |
| F2 | Authentication configuration | Must Have | Set-GitHubAuthentication for token setup |
| F3 | Token persistence | Must Have | Save encrypted token to survive sessions |
| F4 | Get single repository | Must Have | Get-GitHubRepo -Owner X -Name Y |
| F5 | List repositories | Must Have | Get-GitHubRepo -Owner X returns all repos |
| F6 | Get issues | Must Have | Get-GitHubIssue -Owner X -Repo Y with filters |
| F7 | Create issue | Must Have | New-GitHubIssue with title, body, labels |
| F8 | Update issue | Should Have | Set-GitHubIssue for modifications |
| F9 | Close issue | Should Have | Remove-GitHubIssue or close via Set |
| F10 | Pagination handling | Should Have | Automatic multi-page fetching |
| F11 | Rate limit handling | Should Have | Detect and report rate limits |
| F12 | Custom object types | Should Have | Rich PSCustomObjects with type names |
| F13 | Pipeline input support | Should Have | Accept piped repos for issues |
| F14 | Pipeline output support | Should Have | Emit objects suitable for chaining |
| F15 | Verbose output | Nice to Have | -Verbose shows API calls |
| F16 | Progress indication | Nice to Have | Show progress for large fetches |
| F17 | Get user info | Nice to Have | Get-GitHubUser for profile data |
| F18 | Search repositories | Nice to Have | Search-GitHubRepo with query |
Non-Functional Requirements
| ID | Requirement | Description |
|---|---|---|
| NF1 | Module loads in < 1 second | No slow operations during import |
| NF2 | API calls timeout after 30 seconds | Configurable timeout |
| NF3 | Credentials never logged | No tokens in verbose output |
| NF4 | PowerShell 5.1 and 7+ compatible | Works on both platforms |
| NF5 | Windows and cross-platform | Works on Linux/macOS with PS 7 |
| NF6 | Meaningful error messages | Translate API errors to helpful messages |
| NF7 | Testable design | Functions should be mockable for testing |
Real World Outcome
After completing this project, you will have a fully functional, installable PowerShell module.
Module Structure on Disk
$HOME\Documents\PowerShell\Modules\GitHubPS\
+-- GitHubPS.psd1 # 50 lines - module manifest
+-- GitHubPS.psm1 # 80 lines - root module with init
+-- Public\
| +-- Get-GitHubRepo.ps1 # 100 lines - list/get repositories
| +-- Get-GitHubIssue.ps1 # 120 lines - list/filter issues
| +-- New-GitHubIssue.ps1 # 80 lines - create issues
| +-- Set-GitHubIssue.ps1 # 90 lines - update issues
| +-- Remove-GitHubIssue.ps1 # 40 lines - close/delete issues
| +-- Set-GitHubAuthentication.ps1 # 70 lines - token management
| +-- Get-GitHubUser.ps1 # 50 lines - user profile
+-- Private\
| +-- Invoke-GitHubApi.ps1 # 100 lines - core API wrapper
| +-- Get-GitHubPaginated.ps1 # 60 lines - pagination handler
| +-- ConvertTo-GitHubObject.ps1 # 150 lines - object transformation
| +-- Get-GitHubHeaders.ps1 # 20 lines - header builder
+-- Types\
| +-- GitHubPS.Types.ps1xml # 80 lines - type extensions
+-- Tests\
+-- GitHubPS.Tests.ps1 # 200+ lines - Pester tests
Example Cmdlet Usage
# Import your module
Import-Module GitHubPS
# First-time setup: authenticate
Set-GitHubAuthentication -Token $env:GITHUB_TOKEN
# Output: Authenticated as octocat
# Save token for future sessions
Set-GitHubAuthentication -Token $env:GITHUB_TOKEN -Persist
# Output: Authenticated as octocat. Token saved to ~/.github-ps/token
# Get a single repository
$repo = Get-GitHubRepo -Owner microsoft -Name vscode
$repo
# Output:
# Name : vscode
# FullName : microsoft/vscode
# Description : Visual Studio Code
# Stars : 154000
# Forks : 27000
# Language : TypeScript
# Url : https://github.com/microsoft/vscode
# ...
# List all repositories for an organization
Get-GitHubRepo -Owner microsoft | Select-Object Name, Stars, Language | Sort-Object Stars -Descending
# Output:
# Name Stars Language
# ---- ----- --------
# vscode 154000 TypeScript
# terminal 91000 C++
# TypeScript 95000 TypeScript
# ...
# Get issues with filtering
Get-GitHubIssue -Owner microsoft -Repo vscode -State open -Labels bug |
Select-Object Number, Title, CreatedAt |
Sort-Object CreatedAt -Descending
# Create a new issue
$issue = New-GitHubIssue -Owner myorg -Repo myproject `
-Title "Bug: Login page crashes on mobile" `
-Body "## Steps to Reproduce`n1. Open on iPhone`n2. Tap login`n3. App crashes" `
-Labels @("bug", "mobile", "high-priority") `
-Assignees @("developer1")
$issue.Number # 42
$issue.Url # https://github.com/myorg/myproject/issues/42
# Update an issue
Set-GitHubIssue -Owner myorg -Repo myproject -Number 42 `
-Labels @("bug", "mobile", "in-progress") `
-Assignees @("developer2")
# Close an issue
Remove-GitHubIssue -Owner myorg -Repo myproject -Number 42
Pipeline Support Examples
# Chain commands naturally
Get-GitHubRepo -Owner microsoft |
Where-Object { $_.Stars -gt 10000 -and $_.Language -eq 'TypeScript' } |
ForEach-Object { Get-GitHubIssue -Owner microsoft -Repo $_.Name -State open } |
Where-Object { $_.Labels -contains 'bug' } |
Select-Object RepoName, Number, Title, CreatedAt |
Export-Csv "typescript-bugs.csv"
# Pipe repositories to issue retrieval
$repos = Get-GitHubRepo -Owner myorg
$repos | Get-GitHubIssue -State open
# Create issues from CSV
Import-Csv "issues-to-create.csv" | ForEach-Object {
New-GitHubIssue -Owner $_.Owner -Repo $_.Repo -Title $_.Title -Body $_.Body
}
# Bulk close old issues
Get-GitHubIssue -Owner myorg -Repo myproject -State open |
Where-Object { $_.UpdatedAt -lt (Get-Date).AddDays(-365) } |
ForEach-Object { Remove-GitHubIssue -Owner myorg -Repo myproject -Number $_.Number -WhatIf }
Installation Process
# Option 1: Manual installation for development
$modulePath = "$HOME\Documents\PowerShell\Modules\GitHubPS"
Copy-Item -Path .\GitHubPS -Destination $modulePath -Recurse -Force
# Option 2: Symlink for active development
$modulePath = "$HOME\Documents\PowerShell\Modules\GitHubPS"
New-Item -ItemType SymbolicLink -Path $modulePath -Target (Resolve-Path .\GitHubPS).Path
# Option 3: Publish to PowerShell Gallery (after testing)
Publish-Module -Path .\GitHubPS -NuGetApiKey $apiKey
# Verify installation
Get-Module -ListAvailable GitHubPS
# Import and use
Import-Module GitHubPS
Get-Command -Module GitHubPS
# Output:
# CommandType Name Version Source
# ----------- ---- ------- ------
# Function Get-GitHubIssue 1.0.0 GitHubPS
# Function Get-GitHubRepo 1.0.0 GitHubPS
# Function Get-GitHubUser 1.0.0 GitHubPS
# Function New-GitHubIssue 1.0.0 GitHubPS
# Function Remove-GitHubIssue 1.0.0 GitHubPS
# Function Set-GitHubAuthentication 1.0.0 GitHubPS
# Function Set-GitHubIssue 1.0.0 GitHubPS
Solution Architecture
Module File Structure
+-------------------------------------------------------------------+
| GitHubPS Module Architecture |
+-------------------------------------------------------------------+
GitHubPS.psd1
(Module Manifest)
|
v
GitHubPS.psm1
(Root Module)
|
+---------------+---------------+
| |
v v
Public/ Private/
(Exported Functions) (Internal Helpers)
| |
+---------+---------+ +---------+---------+
| | | | | | | | | |
v v v v v v v v v v
Get- Get- New- Set- Remove- Invoke- Get- ConvertTo-
GitHub GitHub GitHub GitHub GitHub GitHub GitHub GitHub
Repo Issue Issue Issue Issue Api Paginated Object
| | | | | ^ ^ ^
| | | | | | | |
+----+----+----+----+-----------+-------+---------+
|
v
$script:Token
$script:BaseUrl
(Module State)
+-------------------------------------------------------------------+
Function Organization
+-------------------------------------------------------------------+
| Function Dependency Graph |
+-------------------------------------------------------------------+
PUBLIC FUNCTIONS (User-Facing)
==============================
Set-GitHubAuthentication
+-- Validates token against API
+-- Stores in $script:GitHubToken
+-- Optionally persists to encrypted file
Get-GitHubRepo
+-- Calls Invoke-GitHubApi for /repos/{owner}/{repo}
| OR /users/{owner}/repos
+-- Uses Get-GitHubPaginated for list operations
+-- Pipes through ConvertTo-GitHubRepo
Get-GitHubIssue
+-- Calls Get-GitHubPaginated for /repos/{owner}/{repo}/issues
+-- Supports filtering via query parameters
+-- Pipes through ConvertTo-GitHubIssue
New-GitHubIssue
+-- Calls Invoke-GitHubApi with POST
+-- Builds JSON body from parameters
+-- Returns single ConvertTo-GitHubIssue result
Set-GitHubIssue
+-- Calls Invoke-GitHubApi with PATCH
+-- Builds JSON body from changed parameters
+-- Returns updated issue
Remove-GitHubIssue
+-- Calls Set-GitHubIssue with State='closed'
+-- OR Invoke-GitHubApi with DELETE (for delete, not close)
PRIVATE FUNCTIONS (Internal)
============================
Invoke-GitHubApi
+-- Core HTTP wrapper
+-- Adds authentication headers
+-- Handles error translation
+-- Manages rate limiting
+-- Returns raw API response
Get-GitHubPaginated
+-- Wraps Invoke-WebRequest (for headers access)
+-- Parses Link headers
+-- Loops until all pages fetched
+-- Returns combined results
ConvertTo-GitHubRepo / ConvertTo-GitHubIssue
+-- Transform raw JSON to PSCustomObject
+-- Add type names
+-- Rename properties to PowerShell conventions
+-- Parse dates to DateTime
Get-GitHubHeaders
+-- Builds standard headers hashtable
+-- Adds Authorization if token exists
+-- Adds Accept and API version
+-------------------------------------------------------------------+
Authentication Flow
+-------------------------------------------------------------------+
| Authentication Lifecycle |
+-------------------------------------------------------------------+
MODULE IMPORT
=============
[Import-Module GitHubPS]
|
v
GitHubPS.psm1 runs
|
v
Check for saved token at ~/.github-ps/token
|
+-- [Token exists] --> Decrypt and load into $script:GitHubToken
| |
| v
| Write-Verbose "Loaded saved token"
|
+-- [No token] --> $script:GitHubToken remains $null
|
v
Write-Verbose "No saved token"
AUTHENTICATION
==============
[Set-GitHubAuthentication -Token "ghp_xxx" -Persist]
|
v
Validate token format (starts with ghp_, gho_, etc.)
|
v
Test token: GET /user
|
+-- [401 Unauthorized] --> throw "Invalid token"
|
+-- [200 OK] --> Store in $script:GitHubToken
|
v
[-Persist specified?]
|
+-- [Yes] --> Encrypt with DPAPI
| |
| v
| Save to ~/.github-ps/token
|
+-- [No] --> Session only
API CALL
========
[Get-GitHubRepo -Owner microsoft -Name vscode]
|
v
Invoke-GitHubApi called
|
v
Check $script:GitHubToken
|
+-- [$null] --> throw "Not authenticated. Run Set-GitHubAuthentication"
|
+-- [Has value] --> Add to headers: Authorization: Bearer $token
|
v
Make API request
|
+-- [401] --> throw "Token expired or revoked"
|
+-- [403 + rate limit] --> throw "Rate limited"
|
+-- [2xx] --> Return response
+-------------------------------------------------------------------+
Phased Implementation Guide
Phase 1: Single API Call Function (2-3 hours)
Goal: Make a single authenticated API request and return the result.
Deliverables:
- Basic folder structure
- Working
Invoke-RestMethodcall to GitHub - Simple function to get a repository
Steps:
- Create folder structure:
mkdir GitHubPS mkdir GitHubPS\Public mkdir GitHubPS\Private - Create a simple standalone script first:
```powershell
test-api.ps1 - Verify API access works
$token = Read-Host “Enter GitHub token” -AsSecureString $tokenPlain = [Runtime.InteropServices.Marshal]::PtrToStringAuto( [Runtime.InteropServices.Marshal]::SecureStringToBSTR($token) )
$headers = @{ ‘Authorization’ = “Bearer $tokenPlain” ‘Accept’ = ‘application/vnd.github+json’ ‘User-Agent’ = ‘GitHubPS/1.0’ }
$repo = Invoke-RestMethod -Uri “https://api.github.com/repos/microsoft/vscode” -Headers $headers $repo | Select-Object name, stargazers_count, language
3. Convert to a function:
```powershell
# Private/Invoke-GitHubApi.ps1
function Invoke-GitHubApi {
param(
[Parameter(Mandatory)]
[string]$Endpoint,
[string]$Token = $env:GITHUB_TOKEN
)
if (-not $Token) {
throw "No token provided. Set GITHUB_TOKEN environment variable."
}
$headers = @{
'Authorization' = "Bearer $Token"
'Accept' = 'application/vnd.github+json'
}
$url = "https://api.github.com$Endpoint"
Invoke-RestMethod -Uri $url -Headers $headers
}
- Create first public function:
# Public/Get-GitHubRepo.ps1 function Get-GitHubRepo { param( [Parameter(Mandatory)] [string]$Owner, [Parameter(Mandatory)] [string]$Name ) Invoke-GitHubApi -Endpoint "/repos/$Owner/$Name" }
Verification:
. .\Private\Invoke-GitHubApi.ps1
. .\Public\Get-GitHubRepo.ps1
$env:GITHUB_TOKEN = "your-token"
Get-GitHubRepo -Owner microsoft -Name vscode
Phase 2: Module Structure (2-3 hours)
Goal: Create proper module structure that imports correctly.
Deliverables:
- Module manifest (
.psd1) - Root module (
.psm1) - Working
Import-Module
Steps:
- Create manifest:
New-ModuleManifest -Path .\GitHubPS\GitHubPS.psd1 ` -RootModule 'GitHubPS.psm1' ` -ModuleVersion '0.1.0' ` -Author 'Your Name' ` -Description 'PowerShell client for GitHub REST API' ` -PowerShellVersion '5.1' ` -FunctionsToExport @('Get-GitHubRepo', 'Set-GitHubAuthentication') - Create root module:
```powershell
GitHubPS.psm1
$script:GitHubToken = $null $script:GitHubBaseUrl = ‘https://api.github.com’
$Public = @(Get-ChildItem -Path “$PSScriptRoot\Public*.ps1” -ErrorAction SilentlyContinue) $Private = @(Get-ChildItem -Path “$PSScriptRoot\Private*.ps1” -ErrorAction SilentlyContinue)
foreach ($file in @($Public + $Private)) { . $file.FullName }
Export-ModuleMember -Function $Public.BaseName
3. Refactor Invoke-GitHubApi to use module state:
```powershell
# Private/Invoke-GitHubApi.ps1
function Invoke-GitHubApi {
param(
[Parameter(Mandatory)]
[string]$Endpoint,
[ValidateSet('Get', 'Post', 'Put', 'Patch', 'Delete')]
[string]$Method = 'Get',
[object]$Body
)
if (-not $script:GitHubToken) {
throw "Not authenticated. Call Set-GitHubAuthentication first."
}
$headers = @{
'Authorization' = "Bearer $script:GitHubToken"
'Accept' = 'application/vnd.github+json'
'User-Agent' = 'GitHubPS/1.0'
}
$params = @{
Uri = "$script:GitHubBaseUrl$Endpoint"
Method = $Method
Headers = $headers
}
if ($Body) {
$params['Body'] = $Body | ConvertTo-Json -Depth 10
$params['ContentType'] = 'application/json'
}
Invoke-RestMethod @params
}
- Create authentication function:
# Public/Set-GitHubAuthentication.ps1 function Set-GitHubAuthentication { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Token ) # Validate by making a test request $headers = @{ 'Authorization' = "Bearer $Token" 'Accept' = 'application/vnd.github+json' } try { $user = Invoke-RestMethod -Uri "$script:GitHubBaseUrl/user" -Headers $headers } catch { throw "Invalid token: $($_.Exception.Message)" } $script:GitHubToken = $Token Write-Output "Authenticated as $($user.login)" }
Verification:
Remove-Module GitHubPS -ErrorAction SilentlyContinue
Import-Module .\GitHubPS\GitHubPS.psd1 -Force
Get-Command -Module GitHubPS
Set-GitHubAuthentication -Token "your-token"
Get-GitHubRepo -Owner microsoft -Name vscode
Phase 3: Authentication Handling (2-3 hours)
Goal: Implement secure credential storage with persistence.
Deliverables:
- Token persistence across sessions
- Secure storage using DPAPI
- Auto-load on module import
Steps:
- Update Set-GitHubAuthentication with persistence:
# Public/Set-GitHubAuthentication.ps1 function Set-GitHubAuthentication { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Token, [switch]$Persist ) # Validate token format if ($Token -notmatch '^(ghp_|gho_|ghu_|ghs_|ghr_)') { Write-Warning "Token doesn't match expected GitHub token format" } # Test token $headers = @{ 'Authorization' = "Bearer $Token" 'Accept' = 'application/vnd.github+json' } try { $user = Invoke-RestMethod -Uri "$script:GitHubBaseUrl/user" -Headers $headers } catch { $statusCode = $_.Exception.Response.StatusCode.value__ if ($statusCode -eq 401) { throw "Invalid or expired token" } throw "Could not validate token: $_" } # Store in module scope $script:GitHubToken = $Token # Persist if requested if ($Persist) { $configDir = "$HOME\.github-ps" if (-not (Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } $secureToken = ConvertTo-SecureString $Token -AsPlainText -Force $encrypted = $secureToken | ConvertFrom-SecureString $encrypted | Out-File "$configDir\token" -Force Write-Verbose "Token saved to $configDir\token" } [PSCustomObject]@{ Login = $user.login Name = $user.name Email = $user.email Persisted = $Persist.IsPresent } } - Add auto-load to module initialization (update GitHubPS.psm1):
# Add to end of GitHubPS.psm1 $tokenPath = "$HOME\.github-ps\token" if (Test-Path $tokenPath) { try { $encrypted = Get-Content $tokenPath -Raw $secureToken = $encrypted | ConvertTo-SecureString $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken) $script:GitHubToken = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) Write-Verbose "Loaded saved GitHub token" } catch { Write-Warning "Could not load saved token: $_" } finally { if ($bstr) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } } - Add Clear-GitHubAuthentication:
# Public/Clear-GitHubAuthentication.ps1 (optional) function Clear-GitHubAuthentication { [CmdletBinding(SupportsShouldProcess)] param() if ($PSCmdlet.ShouldProcess("GitHub authentication", "Clear")) { $script:GitHubToken = $null $tokenPath = "$HOME\.github-ps\token" if (Test-Path $tokenPath) { Remove-Item $tokenPath -Force Write-Verbose "Removed saved token" } Write-Output "Authentication cleared" } }
Phase 4: Pagination (2-3 hours)
Goal: Implement automatic pagination for list endpoints.
Deliverables:
- Pagination helper function
- Updated Get-GitHubRepo to list all repos
- MaxResults parameter for limiting
Steps:
- Create pagination helper:
# Private/Get-GitHubPaginated.ps1 function Get-GitHubPaginated { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Endpoint, [hashtable]$QueryParameters = @{}, [int]$PerPage = 100, [int]$MaxResults = [int]::MaxValue ) if (-not $script:GitHubToken) { throw "Not authenticated. Call Set-GitHubAuthentication first." } $headers = @{ 'Authorization' = "Bearer $script:GitHubToken" 'Accept' = 'application/vnd.github+json' 'User-Agent' = 'GitHubPS/1.0' } # Build initial URL $QueryParameters['per_page'] = [Math]::Min($PerPage, 100) $queryString = ($QueryParameters.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '&' $url = "$script:GitHubBaseUrl$Endpoint`?$queryString" $results = @() $pageCount = 0 do { $pageCount++ Write-Verbose "Fetching page $pageCount`: $url" # Use Invoke-WebRequest to access headers $response = Invoke-WebRequest -Uri $url -Headers $headers -ErrorAction Stop $items = $response.Content | ConvertFrom-Json if ($items -is [Array]) { $results += $items } else { $results += @($items) } Write-Verbose "Page $pageCount`: Got $($items.Count) items (total: $($results.Count))" # Check if we have enough if ($results.Count -ge $MaxResults) { Write-Verbose "Reached MaxResults limit" break } # Parse Link header for next page $url = $null $linkHeader = $response.Headers['Link'] if ($linkHeader) { foreach ($link in ($linkHeader -split ',')) { if ($link -match '<([^>]+)>;\s*rel="next"') { $url = $Matches[1] break } } } } while ($url) # Return limited results $results | Select-Object -First $MaxResults } - Update Get-GitHubRepo with list support:
# Public/Get-GitHubRepo.ps1 function Get-GitHubRepo { [CmdletBinding(DefaultParameterSetName = 'Single')] param( [Parameter(Mandatory, Position = 0)] [string]$Owner, [Parameter(Mandatory, ParameterSetName = 'Single', Position = 1)] [string]$Name, [Parameter(ParameterSetName = 'List')] [ValidateSet('all', 'owner', 'public', 'private', 'member')] [string]$Type = 'all', [Parameter(ParameterSetName = 'List')] [ValidateSet('created', 'updated', 'pushed', 'full_name')] [string]$Sort = 'full_name', [Parameter(ParameterSetName = 'List')] [ValidateSet('asc', 'desc')] [string]$Direction = 'asc', [Parameter(ParameterSetName = 'List')] [int]$MaxResults = [int]::MaxValue ) if ($PSCmdlet.ParameterSetName -eq 'Single') { $response = Invoke-GitHubApi -Endpoint "/repos/$Owner/$Name" ConvertTo-GitHubRepo -InputObject $response } else { $params = @{ type = $Type sort = $Sort direction = $Direction } # Try user repos first, then org repos try { $response = Get-GitHubPaginated -Endpoint "/users/$Owner/repos" ` -QueryParameters $params -MaxResults $MaxResults } catch { $response = Get-GitHubPaginated -Endpoint "/orgs/$Owner/repos" ` -QueryParameters $params -MaxResults $MaxResults } $response | ForEach-Object { ConvertTo-GitHubRepo -InputObject $_ } } } - Create object converter:
```powershell
Private/ConvertTo-GitHubObject.ps1
function ConvertTo-GitHubRepo { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [object]$InputObject )
process { [PSCustomObject]@{ PSTypeName = ‘GitHub.Repository’ Id = $InputObject.id Name = $InputObject.name FullName = $InputObject.full_name Description = $InputObject.description Url = $InputObject.html_url CloneUrl = $InputObject.clone_url Stars = $InputObject.stargazers_count Forks = $InputObject.forks_count OpenIssues = $InputObject.open_issues_count Language = $InputObject.language IsPrivate = $InputObject.private IsArchived = $InputObject.archived IsFork = $InputObject.fork Owner = $InputObject.owner.login CreatedAt = [DateTime]$InputObject.created_at UpdatedAt = [DateTime]$InputObject.updated_at _Raw = $InputObject } } }
function ConvertTo-GitHubIssue { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [object]$InputObject )
process {
[PSCustomObject]@{
PSTypeName = 'GitHub.Issue'
Id = $InputObject.id
Number = $InputObject.number
Title = $InputObject.title
Body = $InputObject.body
State = $InputObject.state
Url = $InputObject.html_url
Labels = ($InputObject.labels | ForEach-Object { $_.name })
Assignees = ($InputObject.assignees | ForEach-Object { $_.login })
Author = $InputObject.user.login
CreatedAt = [DateTime]$InputObject.created_at
UpdatedAt = [DateTime]$InputObject.updated_at
ClosedAt = if ($InputObject.closed_at) { [DateTime]$InputObject.closed_at } else { $null }
Comments = $InputObject.comments
_Raw = $InputObject
}
} } ```
Phase 5: Pipeline Support and Polish (3-4 hours)
Goal: Add pipeline support, error handling, and final polish.
Deliverables:
- Pipeline input/output on all functions
- Comprehensive error handling
- Verbose and progress output
- Additional cmdlets (issues)
Steps:
- Add pipeline support to Get-GitHubIssue:
# Public/Get-GitHubIssue.ps1 function Get-GitHubIssue { [CmdletBinding(DefaultParameterSetName = 'ByRepo')] param( [Parameter(Mandatory, ParameterSetName = 'ByRepo')] [string]$Owner, [Parameter(Mandatory, ParameterSetName = 'ByRepo')] [string]$Repo, [Parameter(Mandatory, ParameterSetName = 'ByPipeline', ValueFromPipeline)] [PSTypeName('GitHub.Repository')] [object]$Repository, [Parameter()] [ValidateSet('open', 'closed', 'all')] [string]$State = 'open', [Parameter()] [string[]]$Labels, [Parameter()] [string]$Assignee, [Parameter()] [ValidateSet('created', 'updated', 'comments')] [string]$Sort = 'created', [Parameter()] [ValidateSet('asc', 'desc')] [string]$Direction = 'desc', [Parameter()] [int]$MaxResults = [int]::MaxValue ) process { if ($PSCmdlet.ParameterSetName -eq 'ByPipeline') { $Owner = $Repository.Owner $Repo = $Repository.Name } $params = @{ state = $State sort = $Sort direction = $Direction } if ($Labels) { $params['labels'] = $Labels -join ',' } if ($Assignee) { $params['assignee'] = $Assignee } $response = Get-GitHubPaginated -Endpoint "/repos/$Owner/$Repo/issues" ` -QueryParameters $params -MaxResults $MaxResults # Filter out pull requests (GitHub's API includes them) $response | Where-Object { -not $_.pull_request } | ForEach-Object { $issue = ConvertTo-GitHubIssue -InputObject $_ # Add repo context $issue | Add-Member -NotePropertyName 'RepoOwner' -NotePropertyValue $Owner $issue | Add-Member -NotePropertyName 'RepoName' -NotePropertyValue $Repo $issue } } } - Add New-GitHubIssue with ShouldProcess:
# Public/New-GitHubIssue.ps1 function New-GitHubIssue { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$Owner, [Parameter(Mandatory)] [string]$Repo, [Parameter(Mandatory)] [string]$Title, [Parameter()] [string]$Body, [Parameter()] [string[]]$Labels, [Parameter()] [string[]]$Assignees, [Parameter()] [int]$Milestone ) $issueData = @{ title = $Title } if ($Body) { $issueData['body'] = $Body } if ($Labels) { $issueData['labels'] = $Labels } if ($Assignees) { $issueData['assignees'] = $Assignees } if ($Milestone) { $issueData['milestone'] = $Milestone } if ($PSCmdlet.ShouldProcess("$Owner/$Repo", "Create issue '$Title'")) { $response = Invoke-GitHubApi -Endpoint "/repos/$Owner/$Repo/issues" ` -Method Post -Body $issueData $issue = ConvertTo-GitHubIssue -InputObject $response $issue | Add-Member -NotePropertyName 'RepoOwner' -NotePropertyValue $Owner $issue | Add-Member -NotePropertyName 'RepoName' -NotePropertyValue $Repo $issue } } - Update Invoke-GitHubApi with comprehensive error handling:
# Private/Invoke-GitHubApi.ps1 (final version) function Invoke-GitHubApi { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Endpoint, [ValidateSet('Get', 'Post', 'Put', 'Patch', 'Delete')] [string]$Method = 'Get', [object]$Body, [hashtable]$QueryParameters, [int]$TimeoutSec = 30 ) if (-not $script:GitHubToken) { throw [System.InvalidOperationException]::new( "Not authenticated. Call Set-GitHubAuthentication first." ) } # Build URL $url = "$script:GitHubBaseUrl$Endpoint" if ($QueryParameters -and $QueryParameters.Count -gt 0) { $queryString = ($QueryParameters.GetEnumerator() | ForEach-Object { "$([Uri]::EscapeDataString($_.Key))=$([Uri]::EscapeDataString($_.Value))" }) -join '&' $url = "$url`?$queryString" } # Build headers (mask token in verbose output) $headers = @{ 'Authorization' = "Bearer $script:GitHubToken" 'Accept' = 'application/vnd.github+json' 'User-Agent' = 'GitHubPS/1.0' 'X-GitHub-Api-Version' = '2022-11-28' } Write-Verbose "API Request: $Method $url" $params = @{ Uri = $url Method = $Method Headers = $headers TimeoutSec = $TimeoutSec ErrorAction = 'Stop' } if ($Body) { $jsonBody = $Body | ConvertTo-Json -Depth 10 $params['Body'] = $jsonBody $params['ContentType'] = 'application/json; charset=utf-8' Write-Verbose "Request Body: $jsonBody" } try { $response = Invoke-RestMethod @params return $response } catch { $statusCode = $null $errorMessage = $null if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode try { $reader = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream()) $errorBody = $reader.ReadToEnd() | ConvertFrom-Json $errorMessage = $errorBody.message } catch { $errorMessage = $_.Exception.Message } } $fullMessage = switch ($statusCode) { 401 { "Authentication failed. Your token may be invalid or expired." } 403 { if ($errorMessage -match 'rate limit') { "Rate limit exceeded. Wait before making more requests." } else { "Access forbidden. You may not have permission for this operation." } } 404 { "Resource not found: $Endpoint" } 422 { "Validation failed: $errorMessage" } default { "API error ($statusCode): $errorMessage" } } $exception = [System.Exception]::new($fullMessage) $exception.Data['StatusCode'] = $statusCode $exception.Data['Endpoint'] = $Endpoint $exception.Data['ApiMessage'] = $errorMessage throw $exception } } - Update manifest with all functions:
# Update GitHubPS.psd1 FunctionsToExport FunctionsToExport = @( 'Get-GitHubRepo', 'Get-GitHubIssue', 'New-GitHubIssue', 'Set-GitHubIssue', 'Remove-GitHubIssue', 'Set-GitHubAuthentication', 'Clear-GitHubAuthentication', 'Get-GitHubUser' )
Testing Strategy
Unit Tests (Pester)
# Tests/GitHubPS.Tests.ps1
BeforeAll {
Import-Module $PSScriptRoot\..\GitHubPS.psd1 -Force
}
Describe "Set-GitHubAuthentication" {
Context "With valid token" {
It "Should authenticate successfully" {
Mock Invoke-RestMethod {
@{ login = 'testuser'; name = 'Test User' }
}
$result = Set-GitHubAuthentication -Token 'ghp_testtoken'
$result.Login | Should -Be 'testuser'
}
}
Context "With invalid token" {
It "Should throw an error" {
Mock Invoke-RestMethod { throw "401 Unauthorized" }
{ Set-GitHubAuthentication -Token 'bad_token' } |
Should -Throw "*Invalid*"
}
}
}
Describe "Get-GitHubRepo" {
BeforeAll {
# Set up test token
$script:GitHubToken = 'test-token'
}
Context "Single repository" {
It "Should return repository object" {
Mock Invoke-GitHubApi {
@{
id = 12345
name = 'test-repo'
full_name = 'owner/test-repo'
stargazers_count = 100
owner = @{ login = 'owner' }
created_at = '2020-01-01T00:00:00Z'
updated_at = '2021-01-01T00:00:00Z'
}
}
$repo = Get-GitHubRepo -Owner owner -Name test-repo
$repo.Name | Should -Be 'test-repo'
$repo.Stars | Should -Be 100
$repo.PSObject.TypeNames | Should -Contain 'GitHub.Repository'
}
}
Context "List repositories" {
It "Should return multiple repositories" {
Mock Get-GitHubPaginated {
@(
@{ id = 1; name = 'repo1'; stargazers_count = 10; owner = @{ login = 'owner' }; created_at = '2020-01-01T00:00:00Z'; updated_at = '2021-01-01T00:00:00Z' }
@{ id = 2; name = 'repo2'; stargazers_count = 20; owner = @{ login = 'owner' }; created_at = '2020-01-01T00:00:00Z'; updated_at = '2021-01-01T00:00:00Z' }
)
}
$repos = Get-GitHubRepo -Owner owner
$repos.Count | Should -Be 2
$repos[0].Name | Should -Be 'repo1'
}
}
}
Describe "Invoke-GitHubApi" {
It "Should throw when not authenticated" {
$script:GitHubToken = $null
{ Invoke-GitHubApi -Endpoint '/test' } |
Should -Throw "*Not authenticated*"
}
It "Should handle rate limit errors" {
$script:GitHubToken = 'test-token'
Mock Invoke-RestMethod {
$response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Forbidden)
$exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new("Rate limit", $response)
throw $exception
}
# This test needs more setup for proper HTTP exception handling
}
}
Integration Tests
| Test | Steps | Expected Result |
|---|---|---|
| Full authentication flow | Set token, verify, persist, reimport | Token loads automatically |
| Get single repo | Get microsoft/vscode | Returns valid repo object |
| Get repo list | List microsoft repos | Returns multiple repos |
| Pagination | List repos with >100 results | All repos returned |
| Create issue | Create test issue | Issue created with URL |
| Pipeline flow | Get repos, pipe to Get issues | Issues returned for each repo |
Manual Testing Checklist
# 1. Basic auth
Set-GitHubAuthentication -Token $env:GITHUB_TOKEN
# 2. Get single repo
Get-GitHubRepo -Owner microsoft -Name vscode
# 3. Get repo list
Get-GitHubRepo -Owner microsoft | Select-Object -First 5 Name, Stars
# 4. Get issues
Get-GitHubIssue -Owner microsoft -Repo vscode -State open -MaxResults 10
# 5. Pipeline
Get-GitHubRepo -Owner microsoft -MaxResults 3 | Get-GitHubIssue -State open -MaxResults 5
# 6. Create issue (on your own repo)
New-GitHubIssue -Owner youruser -Repo yourrepo -Title "Test" -Body "Testing" -WhatIf
# 7. Persistence
Set-GitHubAuthentication -Token $env:GITHUB_TOKEN -Persist
Remove-Module GitHubPS
Import-Module GitHubPS
Get-GitHubRepo -Owner microsoft -Name vscode # Should work without re-auth
# 8. Error handling
Get-GitHubRepo -Owner nonexistent -Name nonexistent # Should give clear error
Common Pitfalls and Debugging Tips
Pitfall 1: JSON Depth Truncation
Problem: Nested objects appear as System.Collections.Hashtable
Cause: ConvertTo-Json default depth is 2
Solution:
# Wrong
$body | ConvertTo-Json
# Right
$body | ConvertTo-Json -Depth 10
Pitfall 2: Token Appearing in Logs
Problem: Token visible in verbose output or error messages
Solution:
# Never do this
Write-Verbose "Using token: $token"
# Do this instead
Write-Verbose "Using token: $($token.Substring(0, 10))..."
# Or mask entirely
Write-Verbose "Using saved authentication"
Pitfall 3: Module Not Finding Functions
Problem: Get-Command -Module GitHubPS returns nothing
Causes:
- FunctionsToExport in manifest doesn’t match actual functions
- Dot-sourcing failed silently
- Export-ModuleMember not called
Solution:
# Check for dot-sourcing errors
$Public = @(Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1" -ErrorAction Stop)
foreach ($file in $Public) {
try {
. $file.FullName
}
catch {
Write-Error "Failed to import $($file.Name): $_" # Don't silently fail
throw
}
}
Pitfall 4: Pagination Missing Items
Problem: Not all items returned for large collections
Cause: Using Invoke-RestMethod which doesn’t expose Link header
Solution: Use Invoke-WebRequest and parse Link header:
$response = Invoke-WebRequest -Uri $url -Headers $headers
$linkHeader = $response.Headers['Link'] # This works
$items = $response.Content | ConvertFrom-Json
Pitfall 5: DPAPI Encryption Cross-Machine
Problem: Token saved on one machine doesn’t work on another
Cause: DPAPI encrypts with machine-specific keys
This is by design - document this limitation:
# In help text
.NOTES
Saved tokens are encrypted with Windows DPAPI and can only be
decrypted by the same user on the same machine.
Pitfall 6: Rate Limiting
Problem: API returns 403 after many requests
Solution: Check rate limit headers and implement backoff:
$response = Invoke-WebRequest -Uri $url -Headers $headers
$remaining = $response.Headers['X-RateLimit-Remaining']
$resetTime = $response.Headers['X-RateLimit-Reset']
if ([int]$remaining -lt 10) {
Write-Warning "Rate limit low: $remaining requests remaining"
}
if ([int]$remaining -eq 0) {
$resetDate = [DateTimeOffset]::FromUnixTimeSeconds([int]$resetTime).LocalDateTime
throw "Rate limited. Resets at $resetDate"
}
Pitfall 7: Pull Requests in Issue List
Problem: Get-GitHubIssue returns pull requests
Cause: GitHub’s Issues API includes PRs
Solution: Filter them out:
$response | Where-Object { -not $_.pull_request }
Debugging Techniques
# Enable verbose output
$VerbosePreference = 'Continue'
Get-GitHubRepo -Owner microsoft -Name vscode -Verbose
# Check module state
Get-Variable -Scope Script
# Test API directly
$headers = @{ 'Authorization' = "Bearer $env:GITHUB_TOKEN" }
Invoke-RestMethod -Uri "https://api.github.com/user" -Headers $headers
# Check what's exported
Get-Command -Module GitHubPS | Format-Table Name, CommandType
# Inspect module
Get-Module GitHubPS | Select-Object -ExpandProperty ExportedFunctions
Extensions and Challenges
Easy Extensions
- Get-GitHubUser - Retrieve user profile information
- Search-GitHubRepo - Implement search API with query syntax
- Get-GitHubOrganization - Get org details and members
- Custom formatting - Create Format.ps1xml for pretty output
Medium Extensions
- Rate limit tracking - Show remaining requests, warn when low
- Retry with backoff - Automatic retry on transient failures
- Caching layer - Cache responses for repeated queries
- Multiple authentication - Support different tokens for different orgs
- Webhook support - Create/manage webhooks
Advanced Extensions
- GraphQL support - Add GitHub GraphQL API support
- Async operations - Use jobs/runspaces for parallel API calls
- CI/CD integration - Publish to PowerShell Gallery automatically
- OAuth device flow - Interactive OAuth for desktop apps
- Mock server - Create test double for offline testing
Challenge Projects
Challenge 1: Build a Jira Module Apply the same patterns to wrap Atlassian Jira’s REST API:
- Different auth (Basic auth with API token)
- Different pagination (offset-based)
- More complex objects (issues have many fields)
Challenge 2: Universal REST Module Build a generic REST API wrapper that can be configured for any API:
- YAML/JSON configuration for endpoints
- Pluggable authentication
- Dynamic cmdlet generation
Books That Will Help
| Topic | Book | Chapter/Section |
|---|---|---|
| PowerShell module architecture | PowerShell in Depth by Don Jones, Jeffrey Hicks, Richard Siddaway | Part 3: “Creating Your Own Scripts and Modules” (Ch. 15-17) |
| Advanced functions and parameters | PowerShell in Depth | Ch. 16: “Advanced Functions and Scripts” |
| REST API design principles | Design and Build Great Web APIs by Mike Amundsen | Ch. 2: “Understanding the REST Style”, Ch. 5: “Hypermedia Designs” |
| HTTP fundamentals | Design and Build Great Web APIs | Ch. 3: “HTTP Semantics for APIs” |
| API authentication | Design and Build Great Web APIs | Ch. 8: “Securing Your API” |
| PowerShell cookbook patterns | The PowerShell Cookbook by Lee Holmes | Ch. 1: “The PowerShell Environment”, Ch. 4: “Working with Objects” |
| Error handling | The PowerShell Cookbook | Ch. 15: “Tracing and Debugging” |
| Pipeline design | Learn PowerShell in a Month of Lunches by Don Jones | Ch. 6-8: Pipeline concepts |
| Credential management | PowerShell in Depth | Ch. 22: “PowerShell Security” |
| Testing with Pester | The Pester Book by Adam Bertram | Full book for testing patterns |
Self-Assessment Checklist
Before considering this project complete, verify each item:
Module Structure
- Module imports without errors (
Import-Module GitHubPS) - Manifest correctly lists all exported functions
- Private functions are not visible to users
- Module loads in under 1 second
- Works on both PowerShell 5.1 and 7+
Authentication
- Set-GitHubAuthentication validates token before storing
- Invalid tokens produce clear error messages
- Token persists across sessions with
-Persist - Saved token auto-loads on module import
- Token is never visible in verbose output or errors
Core Functionality
- Get-GitHubRepo works for single repository
- Get-GitHubRepo lists all repositories for user/org
- Get-GitHubIssue retrieves issues with filtering
- New-GitHubIssue creates issues correctly
- Pull requests are filtered from issue results
Pagination
- List operations retrieve more than 100 items
- MaxResults parameter limits results correctly
- Verbose output shows pagination progress
Pipeline Support
- Repositories can be piped to Get-GitHubIssue
- Output objects work with Select-Object, Where-Object
- Object type names are set correctly
Error Handling
- 401 errors produce “authentication failed” message
- 404 errors produce “not found” message
- 403 rate limit errors are detected and reported
- Network timeouts are handled gracefully
- Errors include relevant context (endpoint, status code)
Polish
- -Verbose shows API calls being made
- -WhatIf works on New/Set/Remove operations
- Help is available (
Get-Help Get-GitHubRepo) - Examples are included in help
- Module is installable to standard path
Testing
- Pester tests pass
- Integration tests work with real API
- Tests don’t require a real token (mocked)
Interview Preparation
Questions You Should Be Able to Answer
- “How do you structure a PowerShell module for maintainability?”
- Separate manifest (.psd1) from code (.psm1)
- Public/ and Private/ folders for exported vs internal functions
- One function per file for easy version control
- Explicit FunctionsToExport in manifest for security
- “What’s the difference between Invoke-RestMethod and Invoke-WebRequest?”
- RestMethod auto-parses JSON, returns content directly
- WebRequest returns full response including headers
- Use WebRequest when you need status codes or headers (pagination)
- RestMethod is faster for simple cases
- “How do you securely store API credentials in PowerShell?”
- Use SecureString for in-memory handling
- Use ConvertFrom-SecureString for DPAPI encryption
- Store encrypted string in user profile
- Never log or display plaintext tokens
- “How do you handle API pagination transparently?”
- Parse Link headers for next page URL
- Loop until no “next” link exists
- Use Invoke-WebRequest to access headers
- Provide MaxResults parameter for limiting
- “What makes a cmdlet ‘pipeline-friendly’?”
- ValueFromPipeline for input objects
- Typed output objects with PSTypeName
- Properties that can be used in Where-Object
- Process block for streaming (not collecting all input first)
- “How do you write testable PowerShell code?”
- Avoid direct calls to external services in public functions
- Create private wrapper functions that can be mocked
- Use dependency injection patterns
- Separate concerns (API calls, data transformation, output)