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:

  1. Create PowerShell modules with proper architecture - Understand the .psd1 manifest, .psm1 root module, and function organization patterns that make modules installable and maintainable
  2. Work with REST APIs using Invoke-RestMethod - Master HTTP methods, headers, request bodies, and response handling in PowerShell
  3. Implement multiple authentication patterns - Handle API keys, OAuth 2.0 tokens, Basic auth, and secure credential storage using SecureString
  4. Transform API responses into rich PowerShell objects - Convert JSON responses into typed PSCustomObjects with meaningful property names
  5. Handle pagination automatically - Implement generators or iterators that transparently handle multi-page API responses
  6. Design pipeline-friendly cmdlets - Accept pipeline input and emit objects that chain naturally with other PowerShell commands
  7. Implement rate limiting and retry logic - Handle API throttling gracefully with exponential backoff
  8. 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:

  1. Basic folder structure
  2. Working Invoke-RestMethod call to GitHub
  3. Simple function to get a repository

Steps:

  1. Create folder structure:
    mkdir GitHubPS
    mkdir GitHubPS\Public
    mkdir GitHubPS\Private
    
  2. 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
}
  1. 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:

  1. Module manifest (.psd1)
  2. Root module (.psm1)
  3. Working Import-Module

Steps:

  1. 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')
    
  2. 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
}
  1. 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:

  1. Token persistence across sessions
  2. Secure storage using DPAPI
  3. Auto-load on module import

Steps:

  1. 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
     }
    }
    
  2. 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)
         }
     }
    }
    
  3. 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:

  1. Pagination helper function
  2. Updated Get-GitHubRepo to list all repos
  3. MaxResults parameter for limiting

Steps:

  1. 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
    }
    
  2. 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 $_ }
     }
    }
    
  3. 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:

  1. Pipeline input/output on all functions
  2. Comprehensive error handling
  3. Verbose and progress output
  4. Additional cmdlets (issues)

Steps:

  1. 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
             }
     }
    }
    
  2. 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
     }
    }
    
  3. 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
     }
    }
    
  4. 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

  1. Get-GitHubUser - Retrieve user profile information
  2. Search-GitHubRepo - Implement search API with query syntax
  3. Get-GitHubOrganization - Get org details and members
  4. Custom formatting - Create Format.ps1xml for pretty output

Medium Extensions

  1. Rate limit tracking - Show remaining requests, warn when low
  2. Retry with backoff - Automatic retry on transient failures
  3. Caching layer - Cache responses for repeated queries
  4. Multiple authentication - Support different tokens for different orgs
  5. Webhook support - Create/manage webhooks

Advanced Extensions

  1. GraphQL support - Add GitHub GraphQL API support
  2. Async operations - Use jobs/runspaces for parallel API calls
  3. CI/CD integration - Publish to PowerShell Gallery automatically
  4. OAuth device flow - Interactive OAuth for desktop apps
  5. 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

  1. “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
  2. “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
  3. “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
  4. “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
  5. “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)
  6. “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)

Resources

Official Documentation

Community Resources