😎

俺の考えた最強の curl ラッパーを考えてみる(jqurl)

2024/12/03に公開

この記事は apstndb Advent Calendar の3日目の記事です。

REST などの Web API を触る皆さんは curl に親しんでいると同時に不満も持っている人が多いのではないかと思います。
curl の代わりに HTTPiePostman 、その他の代替を使っている人も居るでしょう。

私自身はこのような要件があるため、あまり前述の選択肢は馴染めませんでした。

  • Google Cloud の REST API を叩くのが主戦場
    • ネストがある一般的な JSON では HTTPie の JSON 記法は特に嬉しくない
  • 操作手順として紹介したりシェルスクリプトに含めたりしたいので GUI はあまり使わない

以前、私の curl のユースケースを元に自分用のラッパーを検討してみたことがあるので、せっかくなのでアドベントカレンダーを埋めるために紹介したいと思います。
これから私の書く記事でこれを使った例が出てくるかもしれません。

私のユースケースにおける curl と外部コマンドの連携の分析

私の curl のユースケースだと、次のようなよくあるユースケースで curl だけでは済まずに外部コマンドを使っていることが多いようでした。

  • curl に Authorization ヘッダを渡す
  • curl の入力の JSON を用意する
  • curl の出力の JSON を処理する

curl に Authorization ヘッダを渡す

Google Cloud の API では一般的に Authorization ヘッダに Bearer トークンとして権限があるアカウントによる適切なスコープで発行した OAuth 2 アクセストークンを渡すことではじめて API を叩く認可が得られます。
API を手で頻繁に叩く人は下記は手癖で入力できる人も多いでしょう。

$ curl -H "Authorization: Bearer $(gcloud auth print-access-token)"

もしくは Google Cloud のドキュメントの何箇所にも参照されている gcurl エイリアスを設定している人も居るかもしれません。

https://cloud.google.com/s/results?hl=en&q=%2B"gcurl"&text=%2B"gcurl"

alias gcurl='curl -H "Authorization: Bearer $(gcloud auth print-access-token)" -H "Content-Type: application/json" '

この記事でも一旦は gcurl エイリアスを使って説明しますが、エイリアスが設定されていないシェルでは使えないことや、 gcloud auth login で設定したアカウントのアクセストークン以外を使いたい際などの拡張性がないという問題があります。

curl が出力する JSON を処理する

一般的に、 REST API の出力は人が読みやすいものであることはあまりありません。何らかのフィルタをかける人がほとんどでしょう。

例えば、 Google Cloud の Spanner のリージョン、マルチリージョンなどの設定、 instance config を取得する projects.instanceConfigs.list API は次のように呼び出すことができます。 (シェル変数 GOOGLE_CLOUD_PROJECT はアクセス可能なプロジェクト)

$ gcurl -s "https://compute.googleapis.com/compute/v1/projects/${GOOGLE_CLOUD_PROJECT}/regions"
{
  "instanceConfigs": [
    {
      "name": "projects/gcpug-public-spanner/instanceConfigs/asia1",
      "displayName": "Asia (Tokyo/Osaka/Seoul)",
      "replicas": [
        {
          "location": "asia-northeast1",
          "type": "READ_WRITE",
          "defaultLeaderLocation": true
        },
        {
          "location": "asia-northeast1",
          "type": "READ_WRITE"
        },
        {
          "location": "asia-northeast2",
          "type": "READ_WRITE"
        },
...

しかしこの出力は大量のフィールドを含む膨大なものです。例えば name のスラッシュ区切りの最後の部分と displayNamequorumType のみの CSV にするにはどうすれば良いでしょうか。
jq にある程度慣れている人であれば、このように簡単に書くことができます。

$ gcurl -s "https://spanner.googleapis.com/v1/projects/${SPANNER_PROJECT}/instanceConfigs" |
    jq -r '.instanceConfigs[] | [(.name | sub("^.*/";"")), .displayName, .quorumType] | @csv'
"asia1","Asia (Tokyo/Osaka/Seoul)","MULTI_REGION"
"dual-region-australia1","Australia Dual Region","DUAL_REGION"
"dual-region-germany1","Germany Dual Region","DUAL_REGION"
"dual-region-india1","India Dual Region","DUAL_REGION"
"dual-region-japan1","Japan Dual Region","DUAL_REGION"
"eur3","Europe (Belgium/Netherlands)","MULTI_REGION"
"eur5","Europe (London/Belgium/Netherlands)","MULTI_REGION"
"eur6","Europe (Netherlands, Frankfurt)","MULTI_REGION"
"nam-eur-asia1","United States, Europe, and Asia (Iowa/Oklahoma/Belgium/Taiwan)","MULTI_REGION"
"nam-eur-asia3","(Iowa/South Carolina/Belgium/Netherlands/Taiwan/Oklahoma)","MULTI_REGION"

curl の入力の JSON を用意する

curl で REST API を叩く時、最も悩ましいのは入力を組み立てることです。

  • 入力となる JSON を用意し、 --data-binary に渡す
  • JSON を入出力する API を叩くため、 Content-Type: application/json, Accept: application/json ヘッダを渡す

これらを素朴に書くとそれだけでも下記のようになってしまいます。

$ curl s --data-binary '{"key": "value"}' --header "Content-Type: application/json" --header "Accept: application/json" https://example.com/endpoint

幸い curl 7.82.0 から --json フラグが追加されたので、上記コマンドは下記のように書けるようになりました。

$ curl -s --json '{"key": "value"}' https://example.com/endpoint

だいぶマシになりましたね。しかしまだ JSON を組み立てる仕事は楽ではありません。一般的に Google Cloud の API の JSON は構造が複雑です。
何重にもネストされた JSON オブジェクトをシェルの文字列の中に書きたくはありません。

例えば Spanner REST API でセッションを生成するために projects.instances.databases.sessions.create を呼ぶ時に app=example ラベルをつけたい場合、下記のように入れ子になった JSON を渡す必要があります。
(GCPUG public Spanner で検証)

$ gcurl --json '{"session":{"labels":{"app": "example"}}}' \
    "https://spanner.googleapis.com/v1/projects/${SPANNER_PROJECT}/instances/${SPANNER_INSTANCE}/databases/${SPANNER_DATABASE}/sessions" 
{
  "name": "projects/gcpug-public-spanner/instances/merpay-sponsored-instance/databases/apstndb-sampledb3/sessions/AL40lrEzItENPjbJhDwhYH0gHSvkcP9AjZ7A1IzdihTzd97jdgogRpyVJ0Tl-Q",
  "labels": {
    "app": "example"
  },
  "createTime": "2024-12-02T14:34:33.128511Z",
  "approximateLastUseTime": "2024-12-02T14:34:33.204136Z"
}

これはまだ単純な例ですが、入れ子になった JSON をシェルの文字列の中に書くのは容易ではありません。
なので、私はよく jq を使って curl の入力を生成します。
jq は -n フラグを指定すると入力を使わずに1回だけ指定したフィルタを処理した結果を返すので、 上記の例は次のように書き換えられます。(出力は省略)

$ jq -n '.session.labels.app = "example"' | gcurl --json @- \
    "https://spanner.googleapis.com/v1/projects/${SPANNER_PROJECT}/instances/${SPANNER_INSTANCE}/databases/${SPANNER_DATABASE}/sessions" 

ネストされた {, } の数を数えたり、フィールド名を " で囲む必要はなくなりました。

このように、 jq は curl の入力を作る手段として有用です。 JSON に詳しくなることでより複雑な入力も生成できるでしょう。

提案するコマンド

ここまでで3つのコマンド連携をまとめると下記のような形で表せるようです。

$ jq -n "${INPUT_FILTER}" | curl --json @- -H "Authorization: Bearer ${TOKEN}" | jq "${OUTPUT_FILTER}"

それぞれをうまくやるコマンドをパイプで組み合わせるのはまさに UNIX 哲学 ですが、日常的に使うのあればもっと使いやすいショートカットを用意することは考えられないでしょうか。

そこで、この jq と curl の組み合わせのラッパーとして働くコマンド、 jqurl を作ってみました。

https://github.com/apstndb/jqurl

なお、 jqurl は公式の jq ではなく itchyny/gojq を使用します。

$ go install github.com/itchyny/gojq@latest
$ go install github.com/apstndb/jqurl@latest

jqurl がどのように前述の問題に対応しているか見ていきましょう。

Authorization ヘッダについては --auth=google を指定することで自動的に Authorization ヘッダに Google Cloud で一般的な Application Default Credentials で取得したトークンが使われます。

$ jqurl --auth=google

出力の jq フィルタは、 URL の後に位置パラメータがある場合にはそのパラメータを引数として指定した gojq が curl の標準出力にパイプされます。また、 --raw-output をはじめとする --*-output という名前のフラグは出力側の gojq に渡ります。

$ jqurl --auth=google -s "https://spanner.googleapis.com/v1/projects/${SPANNER_PROJECT}/instanceConfigs" \
      --raw-output '.instanceConfigs[] | [(.name | sub("^.*/";"")), .displayName, .quorumType] | @csv'

入力の jq フィルタは、 --data-jq ${filter} を渡すことで gojq -n "${filter}" | curl --json @- 相当のパイプとフラグが設定されます。

$ jqurl --auth=google --data-jq '.session.labels.app = "example"' "https://spanner.googleapis.com/v1/projects/${SPANNER_PROJECT}/instances/${SPANNER_INSTANCE}/databases/${SPANNER_DATABASE}/sessions"

入力側と出力側のそれぞれの gojq にわたるフラグは --iopts, --oopts フラグで任意で設定することもできます。
あまり意味のある例ではありませんが、 --arg で入力側の gojq に変数を渡し、出力側の gojq に --yaml-output(jq にはない gojq の機能) を渡してみましょう。今回は出力側の gojq は YAML に変換するためだけに使うためフィルタは必要ありません。

$ jqurl -s --auth=google --data-jq '.session.labels.app=$app_name' --iopts='--arg app_name example2' --oopts='--yaml-output' \
    "https://spanner.googleapis.com/v1/projects/SPANNER_PROJECT/instances/SPANNER_INSTANCE/databases/SPANNER_DATABASE/sessions"
approximateLastUseTime: "2024-12-02T15:59:02.640095Z"
createTime: "2024-12-02T15:59:02.564911Z"
labels:
  app: example2
name: projects/gcpug-public-spanner/instances/merpay-sponsored-instance/databases/apstndb-sampledb3/sessions/AL40lrGVfdGO6EpWwX0CmbQmGO8Oo126WhUYuWZBfhXSILV4Lm9Lj0kI5auukA

(--*-output の名前を持つフラグは出力側の gojq 渡されるため --yaml-output は実際には --oopts に入れる必要はありません。)

単なるラッパーなので、 jqurl がない環境でも実行可能なコマンドを出力できるようにしても便利でしょう。ということで --dry-run を実装してみました。

$ jqurl --dry-run -s --auth=google --data-jq '.session.labels.app=$app_name' --iopts='--arg app_name example2' --oopts='--yaml-output' \
    "https://spanner.googleapis.com/v1/projects/fake-project/instances/fake-instance/databases/fake-database/sessions"
gojq --arg app_name example2 -n '.session.labels.app=$app_name' | curl -s https://spanner.googleapis.com/v1/projects/fake-project/instances/fake-instance/databases/fake-database/sessions --json @- -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" | gojq --yaml-output

まとめ

この記事では私が Google Cloud の API を叩く上で curl を組み合わせる必要があるコマンドのユースケースを満たす curl ラッパーを考え、実装してみました。
わりと便利なので今後も改良したり自分の書く記事で使ってみようと考えています。

皆さんも俺が考えた最強の curl ラッパーを考え、実装してみませんか?

Discussion