結論

  • hurl の変数はファイルごとに作り直される。hurl login.hurl me.hurl で login.hurl が capture した値は me.hurl に届かず、me.hurl は Undefined variable で exit 3 になる
  • --variable の値は、数字だけなら数値として読まれる。+819098765432 は 819098765432、09012345678 は 9012345678、1.50 は 1.5 として届く。書いた文字のまま渡すなら "..." で囲む
  • --variables-file は値の $VAR も $(...) も展開しない。同じ名前を --variable でも渡すと、渡す順に関係なく --variable の値が使われる
  • 環境変数で届くのは HURL_VARIABLE_<name> と HURL_SECRET_<name> だけで、HURL_<name> は届かない。ファイルで使わない変数を渡しても、エラーも警告も出ない
  • jsonpath の結果は件数で型が変わる。フィルタの一致が 0 件なら値なし (none)、1 件ならその値、2 件以上ならリストになる。1 件の count == 1 は Filter error で落ち、2 件を capture して {{}} で埋め込むと Unrenderable expression で落ちる
  • 値が null なのとキーが無いのは別の状態で、not exists はキーが無いときだけ通る。ただし親が null なら、その下のキーへの not exists は通る

以下は hurl 8.0.1 / bash 5.3.15 / linux/amd64 で実測した。要求は手元で立てたサーバ (/echo は受けた要求の X- ヘッダを JSON で返し、それ以外のパスは同名の JSON ファイルを返す) に送った。

前提: 変数は型を持ち、ファイルごとに作り直される

値は型を持ったまま、ファイルごとの変数の表に入る。{{name}} は値を文字列に直して埋め込み、リストは埋め込めない

hurl の変数に値を入れる経路は 4 つある。

  1. --variable name=value (と --secret name=value)
  2. --variables-file <file> (1 行に 1 つ name=value)
  3. 環境変数 HURL_VARIABLE_name (と HURL_SECRET_name)
  4. ファイルの中の [Captures]

値は文字列とは限らない。--variable の値は、数字だけなら数値として読まれる。jsonpath の結果は文字列・数値・オブジェクト・リストのどれにもなり、capture はその型のまま持つ。{{name}} は値を文字列に直して埋め込むので、書いた文字と届く文字が変わることがあり、リストは埋め込めない。

変数の表は、ファイル 1 つを実行するたびに作り直される。

以下の節は、変数が届く範囲 (ファイルと経路) と、値の型 (--variable の推論、jsonpath の件数、null) の順に扱う。

変数はファイルをまたがない

login.hurl で capture した token は login.hurl の表にだけ入り、me.hurl の表は空から始まる

man hurl は --continue-on-error の説明で次のとおり書いている。

All the input files are executed independently. The result of one file does not affect the execution of the other Hurl files.

login.hurl で capture した token を、me.hurl で使う。

login.hurl
GET http://127.0.0.1:8765/login.json
HTTP 200
[Captures]
token: jsonpath "$.token"
me.hurl
GET http://127.0.0.1:8765/echo
X-Token: {{token}}
HTTP 200
$ hurl login.hurl me.hurl; echo "exit=$?"
{"token":"tok-123"}
error: Undefined variable
  --> me.hurl:2:12
   |
   | GET http://127.0.0.1:8765/echo
 2 | X-Token: {{token}}
   |            ^^^^^ you must set the variable token
   |

exit=3

login.hurl は通って応答を出力し、me.hurl が Undefined variable で落ちる。2 つを 1 つのファイルに結合すると、token が届く。

$ cat login.hurl me.hurl > merged.hurl
$ hurl merged.hurl; echo "exit=$?"
{"method": "GET", "path": "/echo", "headers": {"X-Token": "tok-123"}, "body": null}
exit=0

hurl のファイルには、別のファイルを読み込む構文が無い (公式の issue #317 で要望されている)。ログインのような共通の手順を使い回すなら、実行前に結合する。ファイルをまたいで値を持ち越すなら、前のファイルの capture を外に書き出し、次の実行に --variable で渡す仕組みを自分で書く。

  • PITFALL: --test を付けると、ファイルは並列に走る (man hurl: “In test mode, files are executed in parallel.")。変数だけでなく実行の順でも、ファイルの間に前後関係は作れない

--variable の値は、数字だけなら数値として読まれる

数字だけの値は数値として読まれ、+ と先頭の 0 が落ちる。”…" で囲むと文字列のまま届く

--variable name=value の value は、+ か - で始まってもよい数字だけの並び (^[+-]?\d+$) なら整数として読まれる。{{}} はその整数を文字列に直して埋め込むので、+ と先頭の 0 が落ちる。

phone.hurl
GET http://127.0.0.1:8765/echo
X-Phone: {{phone}}
HTTP 200
$ hurl --variable phone=+819098765432 phone.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Phone": "819098765432"}, "body": null}
$ hurl --variable phone=09012345678 phone.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Phone": "9012345678"}, "body": null}
$ hurl --variable phone=090-1234-5678 phone.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Phone": "090-1234-5678"}, "body": null}

ハイフンを含む値は文字列のまま届く。小数も数値として読まれ、1.50 は 1.5 になる (value.hurl は phone.hurl のヘッダを X-Value: {{v}} にしたもの)。

$ hurl --variable v=1.50 value.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Value": "1.5"}, "body": null}

値を "..." で囲むと文字列として読まれ、書いたとおりに届く。シェルのクォートとは別に、hurl へ渡す文字列の中に " を含める。

$ hurl --variable 'phone="+819098765432"' phone.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Phone": "+819098765432"}, "body": null}
$ hurl --variable 'phone="09012345678"' phone.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Phone": "09012345678"}, "body": null}
$ hurl --variable v='"1.50"' value.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Value": "1.50"}, "body": null}

--variables-file の行と環境変数 HURL_VARIABLE_<name> の値も、同じ規則で読まれる。

$ printf 'phone=+819098765432\n' > vars.txt
$ hurl --variables-file vars.txt phone.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Phone": "819098765432"}, "body": null}
$ printf 'phone="+819098765432"\n' > vars.txt
$ hurl --variables-file vars.txt phone.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Phone": "+819098765432"}, "body": null}

シェルの配列で組み立てるなら、値を "..." で囲んで入れる。

args+=(--variable "${key}=\"${value}\"")
  • PITFALL: ハイフンを含む ID などは文字列のまま届くので、数字だけの値 (電話番号、郵便番号、先頭が 0 の ID) だけが崩れる。ログで値を伏せていると、消えた + や 0 に気づけない

--variables-file は値を展開しない

–variables-file は $VAR も $(…) も展開しない。同じ名前は、渡す順に関係なく –variable の値が使われる

--variables-file の行は name=value の形で、value は書いた文字のまま読まれる。$VAR も $(...) も展開しない。

show.hurl
GET http://127.0.0.1:8765/echo
X-User: {{user}}
X-Ts: {{ts}}
HTTP 200
$ cat local.vars
user=$USER_ID
ts=$(date +%s)
$ export USER_ID=u-001
$ hurl --variables-file local.vars show.hurl
{"method": "GET", "path": "/echo", "headers": {"X-User": "$USER_ID", "X-Ts": "$(date +%s)"}, "body": null}

process substitution (<(...)) で展開した結果も読める。envsubst が展開するのは $VAR だけなので、$(...) は残る。

$ hurl --variables-file <(envsubst < local.vars) show.hurl
{"method": "GET", "path": "/echo", "headers": {"X-User": "u-001", "X-Ts": "$(date +%s)"}, "body": null}

$(...) まで評価するなら、bash で source してから、名前ごとに間接展開 (${!k}) で値を取り出して --variable で渡す。

source local.vars
args=()
while IFS='=' read -r k _; do
  args+=(--variable "$k=\"${!k}\"")
done < local.vars
hurl "${args[@]}" show.hurl
{"method": "GET", "path": "/echo", "headers": {"X-User": "u-001", "X-Ts": "1792730195"}, "body": null}

同じ名前を --variables-file と --variable の両方で渡すと、どちらを後に書いても --variable の値が使われる。

$ printf 'user=from-file\nts=1\n' > both.vars
$ hurl --variables-file both.vars --variable user=from-option show.hurl
{"method": "GET", "path": "/echo", "headers": {"X-User": "from-option", "X-Ts": "1"}, "body": null}
$ hurl --variable user=from-option --variables-file both.vars show.hurl
{"method": "GET", "path": "/echo", "headers": {"X-User": "from-option", "X-Ts": "1"}, "body": null}

man hurl は --variables-file について “Note that defining a variable twice produces an error.” と書いている。8.0.1 では、1 つのファイルに同じ名前を 2 回書いても、--variable を 2 回渡しても、エラーにならず後の値が使われた。

$ printf 'user=a\nuser=b\nts=1\n' > dup.vars
$ hurl --variables-file dup.vars show.hurl; echo "exit=$?"
{"method": "GET", "path": "/echo", "headers": {"X-User": "b", "X-Ts": "1"}, "body": null}
exit=0
$ hurl --variable user=a --variable user=b --variable ts=1 show.hurl; echo "exit=$?"
{"method": "GET", "path": "/echo", "headers": {"X-User": "b", "X-Ts": "1"}, "body": null}
exit=0
  • PITFALL: 既定値を --variable で渡し、環境ごとの値を --variables-file で後から上書きする作りにすると、上書きは効かず既定値のまま走る

環境変数は HURL_VARIABLE_ を付けたものだけが届く

環境変数で変数として届くのは HURL_VARIABLE_token と HURL_SECRET_token で、HURL_token は Undefined variable になる

man hurl は --variable の項に “Environment variables: HURL_VARIABLE_name” と書いている。接頭辞が HURL_ だけの環境変数は、変数にならない。

show.hurl
GET http://127.0.0.1:8765/echo
X-Token: {{token}}
HTTP 200
$ HURL_token=t-env hurl show.hurl; echo "exit=$?"
error: Undefined variable
  --> show.hurl:2:12
   |
   | GET http://127.0.0.1:8765/echo
 2 | X-Token: {{token}}
   |            ^^^^^ you must set the variable token
   |

exit=3
$ HURL_VARIABLE_token=t-env hurl show.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Token": "t-env"}, "body": null}
$ HURL_SECRET_token=t-secret hurl show.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Token": "t-secret"}, "body": null}

環境変数と --variable の両方で渡すと --variable の値が使われる。HURL_VARIABLE_ の値も型が推論される。

$ HURL_VARIABLE_token=t-env hurl --variable token=t-opt show.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Token": "t-opt"}, "body": null}
$ HURL_VARIABLE_token=+81901 hurl show.hurl
{"method": "GET", "path": "/echo", "headers": {"X-Token": "81901"}, "body": null}

ファイルで使わない変数を渡しても、エラーも警告も出ない。名前を打ち間違えても、ファイル側がその変数を使っていなければ検知されない。

$ hurl --variable token=t1 --variable tokn=t2 show.hurl; echo "exit=$?"
{"method": "GET", "path": "/echo", "headers": {"X-Token": "t1"}, "body": null}
exit=0

全部の変数をどのファイルにも渡す作りにしてよい。ログに出す変数をファイルが使うものに絞るなら、ファイルから {{name}} の名前を抜き出す。

$ grep -oE '\{\{[[:space:]]*[a-zA-Z_][a-zA-Z0-9_]*' show.hurl | sed -E 's/^\{\{[[:space:]]*//' | sort -u
token

外から渡したトークンをヘッダに書くときは、ヘッダ行をリクエスト行の直後、[Query] などのセクションより前に置く。セクションの後に書いた name: value の行は、エラーにならずそのセクションの項目として読まれる。

header-after-section.hurl
GET http://127.0.0.1:8765/echo
[Query]
page: 1
X-Token: {{token}}
HTTP 200
$ hurl --variable token=t1 header-after-section.hurl; echo "exit=$?"
{"method": "GET", "path": "/echo?page=1&X-Token=t1", "headers": {}, "body": null}
exit=0
  • PITFALL: トークンがヘッダではなくクエリ文字列で送られる。サーバがそのヘッダを必須にしていなければ 200 が返り、テストは通る。assert をわざと壊して落ちることを確かめ、その要求が検査されているかを見る

jsonpath の結果は件数で型が変わる

フィルタの一致が 0 件なら none、1 件ならその値そのもの、2 件以上ならリスト。count は 1 件で Filter error になる

hurl 8.0.1 の jsonpath は、フィルタ ([?(...)]) の一致が 0 件なら値なし (none)、1 件ならその値そのもの、2 件以上ならリストを返す。count の入力はリストなど (エラーの文言では list, bytes or nodeset) に限られるので、1 件のときは型のエラーで落ちる。

応答の JSON と assert 1 行の組を並べて走らせた (FAIL の下は、hurl のエラーから actual などの行を抜き出したもの)。

{"items":[{"id":1}]}  jsonpath "$.items[?(@.id==2)]" not exists      PASS
{"items":[{"id":1}]}  jsonpath "$.items[?(@.id==2)]" isEmpty         FAIL
    error: Assert failure
    actual:   none
    expected: empty
{"items":[{"id":1}]}  jsonpath "$.items[?(@.id==2)]" count == 0      FAIL
    error: Filter error
    missing value to apply filter
{"items":[{"id":1}]}  jsonpath "$.items[?(@.id==1)].id" == 1         PASS
{"items":[{"id":1}]}  jsonpath "$.items[?(@.id==1)]" count == 1      FAIL
    error: Filter error
    invalid filter input type
    actual:   object
    expected: list, bytes or nodeset

ワイルドカード ([*]) の一致が 1 件のときも、その値そのものになる。配列を指すパス ($.items) は、要素が 0 件でも 1 件でも配列のまま。

{"items":[{"id":1}]}  jsonpath "$.items[*].id" count == 1            FAIL
    error: Filter error
    invalid filter input type
    actual:   integer
    expected: list, bytes or nodeset
{"items":[{"id":1}]}  jsonpath "$.items[*].id" == 1                  PASS
{"items":[]}          jsonpath "$.items" isEmpty                     PASS
{"items":[]}          jsonpath "$.items" count == 0                  PASS
{"items":[{"id":1}]}  jsonpath "$.items" count == 1                  PASS

「条件に合う要素が 1 件ある」を、cash が 1 件の応答と 2 件の応答で比べる。

items-1.json: {"items":[{"id":"a1","type":"cash"},{"id":"c1","type":"card"}]}
items-2.json: {"items":[{"id":"a1","type":"cash"},{"id":"a2","type":"cash"}]}
assertcash 1 件cash 2 件
jsonpath "$.items[?(@.type=='cash')]" count == 1Filter error (actual: object)FAIL (actual: integer <2>)
jsonpath "$.items[?(@.type=='cash')]" existsPASSPASS
jsonpath "$.items[?(@.type=='cash')].id" == "a1"PASSFAIL (actual: list <[a1,a2]>)

count == 1 はどちらの件数でも通らない。exists は 2 件でも通る。「1 件だけ」を確かめるなら、値そのものを比べる。

日報によると、7.x では一致が 1 件でもリストのまま返り、8.0.1 へ上げたときに count == 1 と nth 0 が型のエラーで落ちた (7.x は手元で確かめていない)。

  • PITFALL: 0 件を isEmpty や count == 0 で確かめると落ちる。消えたことを確かめるなら not exists を使う
  • PITFALL: CI とローカルで hurl の版が違うと、同じ assert が片方だけで落ちる。版を揃える

capture は件数で後続の要求を壊す

フィルタだけの capture は 2 件で、first と nth 0 は 1 件で落ちる。件数に依らず 1 件目を取れるのは本文への regex だけ

一覧から条件に合う 1 件の ID を取り出し、後続の要求へ渡す。

plain.hurl
GET http://127.0.0.1:8765/items-{{n}}.json
HTTP 200
[Captures]
id: jsonpath "$.items[?(@.type=='cash')].id"

GET http://127.0.0.1:8765/echo
X-Id: {{id}}
HTTP 200

capture の行だけを替えた 4 通りを、cash が 0 / 1 / 2 件の応答で走らせた (0 件は 4 通りとも plain と同じ No query result なので、plain 以外は省いた)。

id: jsonpath "$.items[?(@.type=='cash')].id"            # plain
id: jsonpath "$.items[?(@.type=='cash')].id" first      # first
id: jsonpath "$.items[?(@.type=='cash')].id" nth 0      # nth
id: regex /"id":"([^"]+)","type":"cash"/                # regex
plain  n=0: exit=3
    error: No query result
    query didn't return any result
plain  n=1: OK {"method": "GET", "path": "/echo", "headers": {"X-Id": "a1"}, "body": null}
plain  n=2: exit=3
    error: Unrenderable expression
    expression with value [a1,a2] can not be rendered
first  n=1: exit=3
    error: Filter error
    invalid filter input type
    actual:   string
    expected: list
first  n=2: OK {"method": "GET", "path": "/echo", "headers": {"X-Id": "a1"}, "body": null}
nth    n=1: exit=3
    error: Filter error
    invalid filter input type
    actual:   string
    expected: list
nth    n=2: OK {"method": "GET", "path": "/echo", "headers": {"X-Id": "a1"}, "body": null}
regex  n=1: OK {"method": "GET", "path": "/echo", "headers": {"X-Id": "a1"}, "body": null}
regex  n=2: OK {"method": "GET", "path": "/echo", "headers": {"X-Id": "a1"}, "body": null}
capture0 件1 件2 件
jsonpath (フィルタだけ)No query resulta1Unrenderable expression
jsonpath + firstNo query resultFilter errora1
jsonpath + nth 0No query resultFilter errora1
本文への regexNo query resulta1a1 (1 件目)

フィルタだけの capture は 1 件でしか動かず、first と nth 0 は 2 件以上でしか動かない。件数に依らず 1 件目を取れるのは、応答の本文に regex を当てる形だけだった。

  • PITFALL: 2 件で落ちたテストに first を足すと、データが 1 件に戻ったときに逆向きに落ちる。テストデータの件数が揺れるたびに、どちらかの向きで落ち続ける
  • PITFALL: regex はキーの並び順に依存する。応答の直列化の順を決めている型が変わると、capture が No query result で落ちる。並び順を決めている型定義の場所を、コメントに残す

null とキーが無いのは別の状態

not exists が通るのはキーが無いときだけ。ただし親が null なら、その下のキーへの not exists は通る

hurl はキーが無いこと (none) と、値が null であることを区別する。

{"user":null}                      jsonpath "$.user" == null            PASS
{"user":null}                      jsonpath "$.user" exists             PASS
{"user":null}                      jsonpath "$.user" not exists         FAIL
    error: Assert failure
    actual:   null <null>
    expected: not something
{}                                 jsonpath "$.user" not exists         PASS
{}                                 jsonpath "$.user" == null            FAIL
    error: Assert failure
    actual:   none
    expected: null

親が null なら、その下のパスは解決しないので、not exists が通る。親がオブジェクトなら、同じ assert が値を見て落ちる。

{"user":null}                      jsonpath "$.user.email" not exists   PASS
{"user":{"email":"a@example.com"}} jsonpath "$.user.email" not exists   FAIL
    error: Assert failure
    actual:   string <a@example.com>
    expected: not something

isNull という述語は無く、構文エラーになる。null は == null で比べる。

{"user":null}                      jsonpath "$.user" isNull             FAIL
    error: Parsing predicate
    expecting a predicate

capture が値を取れないときは、その手順が capture の行で落ち、同じ手順の [Asserts] は評価されない。

capture.hurl
GET http://127.0.0.1:8765/user-null.json
HTTP 200
[Captures]
email: jsonpath "$.user.email"
[Asserts]
jsonpath "$.user.email" == "never@example.com"
$ hurl --no-output capture.hurl; echo "exit=$?"
error: No query result
  --> capture.hurl:4:8
   |
   | GET http://127.0.0.1:8765/user-null.json
   | ...
 4 | email: jsonpath "$.user.email"
   |        ^^^^^^^^^^^^^^^^^^^^^^^ query didn't return any result
   |

exit=3

落ちるはずの assert (== "never@example.com") の失敗は出ず、capture の失敗だけが出る。

  • PITFALL: 「user.email が応答に載っていないこと」を jsonpath "$.user.email" not exists で確かめても、相手が {"user": null} を返すと、その assert は何も検査していない。Go で omitempty の無い nil ポインタを返すと null になる ( Go の JSON が警告なしに潰す null と欠落 )。親がオブジェクトになる状態を作ってから assert し、載せる実装に変えて落ちるかを確かめる
  • PITFALL: capture と同じパスに exists の assert を書いても、capture が先に落ちるので、その assert は評価されない
  • PITFALL: not exists を書く前に、実際の応答でキーが無いのか null なのかを確かめる。null を返す API への not exists は落ちる

まとめ

場面起きること対策
複数ファイル変数はファイルごとに作り直され、capture は次のファイルに届かない共通の手順は結合して 1 ファイルにする。持ち越す値は --variable で渡す
--variable の値数字だけの値は数値になり、+ と先頭の 0 が落ち、1.50 は 1.5値を "..." で囲む
--variables-file$VAR も $(...) も展開しない。同じ名前は --variable が勝つbash で source して --variable で渡す。上書きを --variables-file に頼らない
環境変数HURL_<name> は届かないHURL_VARIABLE_<name> を使う
使わない変数エラーも警告も出ない全部渡してよい。ログはファイルの {{name}} を grep して絞る
ヘッダ行の位置セクションの後に書くと、そのセクションの項目になるヘッダはリクエスト行の直後に書く
jsonpath の件数0 件は none、1 件はその値、2 件以上はリスト。1 件の count は Filter error0 件は not exists、1 件は値を比べる
capture の件数フィルタだけは 2 件で、first / nth 0 は 1 件で落ちる件数が揺れるなら本文への regex で取る
null とキー無しnot exists はキーが無いときだけ通る。親が null ならその下は通る親がオブジェクトの状態で assert し、反転させて落ちるか確かめる

参考