PowerShell + curl.exe で REST API を扱った際につまずいたあれこれ~Redmine へのREST API アクセス~

## はじめに
本記事はPowerShell でcurl.exe を使用し、REST APIを用いた操作をする際につまずいた内容と解決方法をまとめたものです。
職場で自由にlinuxが使えない方~~Windows環境に縛られている方~~ などのお役に立てれば幸いです。

通常なら`Invoke-WebRequest` を使用しますが、今回クライアント証明書による認証が必要なため`curl.exe` を使用しています。

## 目次
* [はじめに](#はじめに)
* [目次](#目次)
* [環境](#環境)
* [結論](#結論)
* [つまずいた内容](#つまずいた内容)
    * [curl が使えない](#curl-が使えない)
    * [変数に入れると文字化けする](#変数に入れると文字化けする)
    * [変数に入れると進捗状況が紛れ込む](#変数に入れると進捗状況が紛れ込む)
    * [ステータスコードが欲しい!](#ステータスコードが欲しい)
    * [curl.exe でPOSTをするとエラーになる](#curlexe-でpostをするとエラーになる)
    * [POST データをファイルにしたのに400が返ってくる](#post-データをファイルにしたのに400が返ってくる)
* [参考](#参考)

## 環境
### PowerShell
```
Name                           Value
----                           -----
PSVersion                      5.1.19041.5007
PSEdition                      Desktop
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0...}
BuildVersion                   10.0.19041.5007
CLRVersion                     4.0.30319.42000
WSManStackVersion              3.0
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
```
### curl.exe
```
curl 8.9.1 (Windows) libcurl/8.9.1 Schannel zlib/1.3 WinIDN
Release-Date: 2024-07-31
Protocols: dict file ftp ftps http https imap imaps ipfs ipns mqtt pop3 pop3s smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS HSTS HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM SPNEGO SSL SSPI threadsafe Unicode UnixSockets
```

REST API は Redmine 5.0.9 に対して行った。

## 結論
* PowerSheell でlinux と同じようなcurl が使用したい場合は curl.exe で始める
* `[Console]::OutputEncoding` をREST API で帰ってくるデータの文字コードに合わせる
    * `[Console]::OutputEncoding=[Text.Encoding]::UTF8` など
* curl.exe でも`”`などがエスケープされるので回避するためにPOSTのデータはファイルに書き出す
* curl.exe のファイル読み込みではBOM付きUTF-8を解釈できないためBOMなしUTF-8 で保存する
    * PowerShell 5.1では `Set-Content` で `-utf8NoBOM` が使えない
    * 代わりに`[Text.Encoding]::UTF8.GetBytes($reqJson) | Set-Content -Encoding Byte` を使う

## つまずいた内容
### curl が使えない
PowerShell で`curl example.com` をするとちゃんと帰ってくる。
だが、実態は`Invoke-WebRequest` のエイリアス。
なので `-d` とかを使おうとするとエラーになる。
```Powershell
PS Path\To\Desktop> curl -d "" example.com
Invoke-WebRequest : 引数 'example.com' を受け入れる位置指定パラメーターが見つかりません。
```
#### 解決策
`curl.exe` で実行ファイルを明示にする

### 変数に入れると文字化けする
`curl.exe https://www.yahoo.co.jp/` をするとちゃんと帰ってくる
だが、
```
$res = https://www.yahoo.co.jp/
$res
```
とすると文字化けする。

```
<title>Yahoo! JAPAN</title><meta name="description" content="縺ゅ↑縺溘・豈取律繧偵い繝・・繝・・繝医☆繧区ュ蝣ｱ繝昴・繧ｿ繝ｫ縲よ､懃ｴ｢縲√ル繝･繝ｼ繧ｹ縲∝､ｩ豌励√せ繝昴・繝・√Γ繝ｼ繝ｫ縲√す繝ｧ繝・ヴ繝ｳ繧ｰ縲√が繝ｼ繧ｯ繧ｷ繝ｧ繝ｳ縺ｪ縺ｩ萓ｿ蛻ｩ縺ｪ繧ｵ繝ｼ繝薙せ繧貞ｱ暮幕縺励※縺・∪縺吶・/><meta name="robots" content="noodp"/>
```

この事象が発生する原因は、PowerShellスクリプトが外部コマンドの実行結果を文字コードがSJISであることを期待しているため。
UTF-8 で帰ってきた外部コマンドの実行結果をPowerShellがSJISと思い文字コードの変換を行うため文字化けする。

#### 解決策
詳しくは分からないが、PowerShellとやり取りする文字コードをUTF-8 にする。

`[Console]::OutputEncoding=[Text.Encoding]::UTF8`

をスクリプトの頭に書いた。

### 変数に入れると進捗状況が紛れ込む
今はどうも再現できないが レスポンスを変数に格納すると進捗状況が紛れ込むようになった。
再現出来たら詳細を書く。

#### 解決策
標準エラー出力をファイルへリダイレクト
```
$(curl.exe example.com 2>garbage.txt)
```
ファイルではなく `nul` (Linuxの/dev/nullに該当？)にするとエラーとなったため適当なファイルに吐き出している。

### ステータスコードが欲しい!
curl.exe からステータスコードを取得できずに苦戦。
コマンド自体の成功、失敗を記録する`$LASTEXITCODE` があるがステータスコードはとれないため不採用に。

#### 解決策
かなり強引なやり方。`-w "%{thhp_code}"`を使用した。
```
$res = curl.exe -w "{%thhp_code}" example.com
$statusCode = $res.Substring($res.Length - 3)
$resJson = $res.Substring(0,$res.Length - 3)
```
~~あまりにも強引すぎる~~

### curl.exe でPOSTをするとエラーになる

上記の問題をクリアしたらcurl.exe で GETをすることはできるようになったと思う。
だが、POSTリクエストで `-d` を使うとエラーになる

`curl.exe -d "空白があると バグる" example.com`
これを直接PowerShell で実行する分にはバグらなかった。(困惑)

結論から言うと `-d` で設定したデータの`""`やら` `がcurl.exe 実行時、コマンドの一部と解釈されていることが原因。
エスケープしてみるとか試したがうまくいかない。

#### 解決策
データを ファイルで書き出して `-d "@data.txt"` のようにして参照する。
この方がエスケープとか考えなくていいから楽まであった。
書き出し方は後述。

### POST データをファイルにしたのに400が返ってくる
ここでさらに問題が発生。
`$data | Set-Content -Path "Path\To\Temp\data.txt" -Encoding UTF8`
で書き出してPOSTを行ったが`400 (Bad Request)` が返ってきた。

原因は`Set-Content`のエンコードにある。
WindwosのUTF-8はUTF-8であるがBOMありなのである。
このBOMの有無はcurl.exeは吸収せずそのまま送るっぽいのでBOM付きでサーバに送信され、`400`となった。
しかも~~立ち悪いことに~~`Set-Content`でUTF-8 BOMなしを選べない。(PowerShell 7なら可能)

#### 解決策
Qiitaの記事を参考にさせていただいた。
```
[Text.Encoding]::UTF8.GetBytes($data) | Set-Content -Encoding Byte -NoNewLine -Path .\data.txt
```
`[Text.Encoding]::UTF8.GetBytes($data)`でBOMなしUTF-8に変換して `Set-Content` の `-Encoding Byte`でそのまま流しているらしい。

## 過程で作成したもの
* 受けたPOSTデータを標準出力に出力し、そのまま返すスクリプト
    * Chat GPT に書いてもらいました

### プロンプト
```
localhost:8081 にアクセスし、POSTリクエストのデータを受け取り標準出力すると同時に
まったく同一のデータを返すPowershellスクリプトを作成してください。
管理者権限が不要な構成でお願いします
```

### スクリプト
```PowerShell
# ポート8081でリスニングするためのHttpListenerオブジェクトを作成
$listener = New-Object System.Net.HttpListener
$listener.Prefixes.Add("http://localhost:8081/")
$listener.Start()
Write-Host "Listening on http://localhost:8081/ ..."

# 無限ループでリクエストを処理
while ($true) {
    # 非同期で接続待ち
    $context = $listener.GetContext()

    # POSTリクエストかを確認
    if ($context.Request.HttpMethod -eq "POST") {
        # リクエストからデータを取得
        $reader = New-Object System.IO.StreamReader($context.Request.InputStream)
        $requestData = $reader.ReadToEnd()
        $reader.Close()

        # 標準出力にリクエストデータを表示
        Write-Host "Received POST data: $requestData"

        # レスポンスとして同じデータを返す
        $response = $context.Response
        $buffer = [System.Text.Encoding]::UTF8.GetBytes($requestData)
        $response.ContentLength64 = $buffer.Length
        $response.OutputStream.Write($buffer, 0, $buffer.Length)
        $response.OutputStream.Close()
    }
}

# 停止する場合はCtrl+C

```

## さいごに
ここまで見てくれてありがとうございます。
誤りなどありましたらコメントいただけますと幸いです。

~~Windowsは文字コードとかでかなり悩まされますね...~~
~~職場でもLinuxが(というかPythonとか)使いたい...~~

## 参考
https://qiita.com/yamachan360/items/8c928738289cd518eec8

https://ascii.jp/elem/000/004/021/4021036/

https://qiita.com/SAITO_Keita/items/120e1286727fb680a699

https://learn.microsoft.com/ja-jp/powershell/module/microsoft.powershell.management/set-content?view=powershell-7.4