Guides

Importing Postman, Hurl and curl

vero import turns what a team already has into a plan on stdout:

vero import postman orders.postman_collection.json > orders.yaml
vero import hurl rate-limit.hurl > rate-limit.yaml
vero import curl -- -X POST -H 'Content-Type: application/json' -d '{"a":1}' https://api.example.com/orders

Three rules hold for every source.

The order is kept. Postman, Hurl and curl run requests one after another, so the plan chains its tasks with explicit needs in source order. The import adds no concurrency the source did not have. vero plan --graph shows the chain as explicit edges.

No holds are written. An importer cannot know which requests share a rate limit, a row or a balance. The chain makes that safe, and it is also what makes the plan slow. Run vero run --repeat 20 --compare-jobs on it, relax the edges that are not real data dependencies, and add holds where the hint points.

Nothing is dropped in silence. Whatever does not translate is listed on stderr and at the end of the plan as # not translated: ..., and the import exits 1. What the importer had to invent is listed as added:, currently only status < 400 on a request the source never checked, because vero requires each request step to have an assertion. That does not change the exit code.

What translates

Source Translated Listed as not translated
Postman collection v2.1 requests in order, folders flattened; variable entries as env with their values as defaults; enabled headers; raw bodies (JSON when they parse) and urlencoded forms; test lines pm.response.to.have.status(N), pm.expect(pm.response.code).to.eql(N) and .to.equal(N); pm.test(...) wrappers and // comments are passed over without a note pre-request scripts, other test lines (including pm.environment.set), auth blocks, formdata and file bodies
Hurl entries in order; request headers; JSON or raw bodies; [QueryStringParams], [FormParams]; HTTP <code> (HTTP * adds no status assertion); implicit response header checks; [Captures] and [Asserts] on status, body, header "X" and simple jsonpath "$.a.b[0]" with ==, !=, exists, not exists, contains filters, other queries and predicates, [Options], [BasicAuth] and other sections, lines before the first request
curl -X, -H, -d and its variants (several are joined with &), --json, -u (as a Basic header), -L (as followRedirects), --url; output flags such as -s, -v, -i, -f and --compressed are ignored -k (vero turns verification off per run, with --insecure-skip-verify), -d @file (use bodyFile; --data-raw @file keeps the text as written, as curl does), every other flag

A variable the source reads, such as {{baseUrl}}, becomes env.baseUrl read from BASEURL with the source's value as default. A variable a Hurl [Captures] line sets becomes a result of that task, and later requests read it as {{ tasks.<task>.results.<name> }}, which also shows the data edge the chain already covers. Postman's script-set variables are not captures and are read from the environment.

The lifecycle is written as strategy: isolated with a comment. This declares an assumption, not a server startup or data cleanup. Choose a reset or unique-data strategy if your target is not already isolated.

Hurl: the rate-limit case

The repository keeps the rate-limit case as a Hurl file, testdata/prior-art/hurl/rate-limit.hurl: reset the key, log in and capture the token, four GETs, the fourth a 429.

POST {{base}}/admin/ratelimit/reset
{"key": "hurl-key"}
HTTP 204

POST {{base}}/auth/token
{"key": "hurl-key"}
HTTP 200
[Captures]
token: jsonpath "$.token"

GET {{base}}/orders
Authorization: Bearer {{token}}
HTTP 200

...

GET {{base}}/orders
Authorization: Bearer {{token}}
HTTP 429
[Asserts]
header "Retry-After" exists

vero import hurl exits 0 on it, since every line translates, and writes six tasks. The two that show the shape:

# Imported by vero import hurl from testdata/prior-art/hurl/rate-limit.hurl.
# The source ran its requests in order, so these tasks are chained with needs in that order:
# the import adds no concurrency the source did not have. It cannot know which requests share a
# budget, such as a rate limit or a row, so it writes no holds. Run
#   vero run --repeat 20 --compare-jobs <this plan>
# to see which edges can go and where holds are needed.
apiVersion: vero.plan/v1
kind: Plan
# isolated assumes each run creates its own data; say reset or teardown-only if it does not (chapter 7.2).
lifecycle: { strategy: isolated }
env:
  base: "${BASE:-}"
tasks:
  - name: 01-post-admin-ratelimit-reset
    steps:
      - http:
          method: POST
          url: "{{ env.base }}/admin/ratelimit/reset"
          json: {"key":"hurl-key"}
        assert:
          - "status == 204"
  - name: 02-post-auth-token
    needs: [01-post-admin-ratelimit-reset]
    steps:
      - http:
          method: POST
          url: "{{ env.base }}/auth/token"
          json: {"key":"hurl-key"}
        assert:
          - "status == 200"
        results:
          token: "body.token"
  ...
  - name: 06-get-orders
    needs: [05-get-orders]
    steps:
      - http:
          method: GET
          url: "{{ env.base }}/orders"
          headers:
            "Authorization": "Bearer {{ tasks.02-post-auth-token.results.token }}"
        assert:
          - "status == 429"
          - "headers[\"Retry-After\"] != nil"

Tasks are named by position and a slug of the method and path, 01-post-admin-ratelimit-reset, with -2 on a repeated name. {{base}} had no value in the file, so it became ${BASE:-}: an empty default, so the plan loads without the variable and fails at the first request. The capture became a result of task 02, and every later task reads tasks.02-post-auth-token.results.token. header "Retry-After" exists became headers["Retry-After"] != nil.

What the chain hides is the point of the plan vero ships for the same case: examples/rate-limit.yaml runs its three order tasks in parallel under one holds, where the imported plan can only run them one after another. Getting from one to the other is the section after the next.

Postman: a collection with a folder

testdata/import/orders.postman_collection.json has two collection variables, a request at the top level, a folder with one request, and a request with a disabled header:

# Imported by vero import postman from testdata/import/orders.postman_collection.json.
...
metadata: { name: "orders api" }
# isolated assumes each run creates its own data; say reset or teardown-only if it does not (chapter 7.2).
lifecycle: { strategy: isolated }
env:
  apiKey: "${APIKEY:-pm-key}"
  baseUrl: "${BASEURL:-http://127.0.0.1:18080}"
tasks:
  - name: 01-health
    steps:
      - http:
          method: GET
          url: "{{ env.baseUrl }}/healthz"
        assert:
          - "status == 200"
  - name: 02-get-token
    needs: [01-health]
    steps:
      - http:
          method: POST
          url: "{{ env.baseUrl }}/auth/token"
          headers:
            "Content-Type": "application/json"
          json: {"key":"{{ env.apiKey }}"}
        assert:
          - "status == 200"
  - name: 03-echo
    needs: [02-get-token]
    steps:
      - http:
          method: GET
          url: "{{ env.baseUrl }}/fixtures/echo"
          headers:
            "X-Trace": "0755"
        assert:
          - "status == 200"

The collection's name became metadata.name. The folder is flattened, so Auth / Get token is task 02 in the order Postman would have run it. The two test scripts were pm.test wrappers around a status check each, and both became status == 200. The disabled X-Old header is gone, and X-Trace: 0755 is quoted, because an unquoted 0755 is an octal number to YAML and vero refuses it.

A collection with scripts shows the other half. scripted.postman_collection.json has one request with a pre-request script and a test script that stores the token for later requests:

$ vero import postman testdata/import/scripted.postman_collection.json > scripted.yaml
not translated: Login: prerequest script (1 lines); vero runs no JavaScript in a plan
not translated: Login: test script line 2: pm.environment.set("token", pm.response.json().token);
vero import: 2 item(s) not translated; the plan lists them at the end
$ echo $?
1

The plan still has the request, with its status == 200, and ends with the same two lines as comments. The token the script stored is the thing to rewrite by hand: a results: entry on the login task, and {{ tasks.01-login.results.token }} where later requests read it, which is how vero derives the edge. Where a collection reads a variable a script set, the import writes an env entry for it, read from the environment, since it cannot see the script run.

curl: one command

$ vero import curl -- -X POST -H 'Content-Type: application/json' -d '{"sku":"ABC-1","qty":2}' https://api.example.com/orders
added: 01-post-orders: the source checks nothing on this request and vero requires an assertion; added status < 400

Everything after -- is the curl command without the word curl. The import exits 0, since nothing was dropped, and the plan is one task:

tasks:
  - name: 01-post-orders
    steps:
      - http:
          method: POST
          url: "https://api.example.com/orders"
          headers:
            "Content-Type": "application/json"
          json: {"qty":2,"sku":"ABC-1"}
        assert:
          - "status < 400"

# added: 01-post-orders: the source checks nothing on this request and vero requires an assertion; added status < 400

The body is JSON because it parsed as JSON and the header said so; a body that does not parse goes out as body. Without -X, a command with data is a POST and one without is a GET. A command with flags the importer does not read is the common case, and each one is named:

$ vero import curl -- -k --retry 3 https://example.com/x
added: 01-get-3: the source checks nothing on this request and vero requires an assertion; added status < 400
not translated: -k: vero turns verification off per run, with vero run --insecure-skip-verify, never in a plan
not translated: flag --retry
not translated: second URL https://example.com/x
vero import: 3 item(s) not translated; the plan lists them at the end

The importer does not know that --retry takes a value, so 3 was read as the URL and the real URL as a second one. The plan says exactly that at its end; fix the URL by hand and consider retry: on the step, which vero offers on its own terms (the http step).

After importing

An imported plan runs, and it runs slowly, because every task waits for the one before it. The work left is to turn the chain into a graph.

  1. Run it as imported, against the target, to confirm it passes: vero run plan.yaml. Set the environment variables the env block names.
  2. Delete the needs lines. A task that reads another task's result keeps its edge through the template reference, as 03-get-orders does to 02-post-auth-token above; vero plan --graph shows what is left.
  3. Run vero run --repeat 20 --compare-jobs --jobs 8 plan.yaml. A task that passes at --jobs 1 and fails above it shares something with another task: a rate limit, a row, a balance. The hint at the end names the candidates. Reading a flake report and the --jobs hint has the output.
  4. Give those tasks a holds on one named resource, and declare it under resources. Tasks that hold the same exclusive resource never overlap, in whichever order, and everything else runs in parallel.
  5. Replace strategy: isolated with the truth about the target: unique with run.id in the names the plan creates, or reset with the steps that put the target in a known state. Plan format has the three strategies.

The result for the Hurl file above is examples/rate-limit.yaml: one login, three tasks that hold ratelimit/orders-api-key, and a run that passes at --jobs 1 and at --jobs 8.

This page is docs/content/import.md in the repository.

verodocs