DEV Community

Cover image for I put my API collection in a folder of curl scripts, and it finally behaved
Ramananda Panda
Ramananda Panda

Posted on

I put my API collection in a folder of curl scripts, and it finally behaved

Chapter 1: The JSON file that ate Friday

It was a Friday evening. The kind where you push one last fix and promise yourself you'll close the laptop in five minutes.

Then a teammate pinged me:

"Hey, can you review my change to the API collection?"

I opened the pull request. It was a single file, Petstore.postman_collection.json. The diff was 1,400 lines. Somewhere in that wall of "id", "_postman_id" and re-ordered keys, one header had changed. Probably. I approved it the way you accept cookie banners: quickly, and with mild guilt.

Later the CI job failed, because the CI runner needed a different tool, a different login and a different copy of the environment. My "five minutes" turned into an evening of archaeology.

That night I asked a dangerous question:

What if an API request was just... a curl command in a file?

Chapter 2: The humble .sh file

Every backend developer already speaks curl. We paste it into Slack, into bug reports and into README files. It is the lingua franca of HTTP.

So I wrote one:

curl -sS "$BASE_URL/pets" \
  -H "Authorization: Bearer $TOKEN"
Enter fullscreen mode Exit fullscreen mode

It worked. It was readable. It diffed beautifully. But it couldn't answer the questions a test suite needs to answer:

  • Did it return the status I expected?
  • Is the body actually right?
  • How do I pass the token from the login call to the next one?

I didn't want a new language for that. Comments, however, are free. Every shell already ignores them.

Chapter 3: Comments that do real work

Here is the same request, with a few annotations on top. This is the actual login file from the Sankh petstore example:

#!/usr/bin/env bash
# @name Log in
# @tags smoke
# @expect status 200
# @expect json .token exists
# @capture TOKEN=.token
curl -sS "$BASE_URL/login" \
  -H 'Content-Type: application/json' \
  -d "{\"username\":\"$PET_USER\",\"password\":\"$PET_PASSWORD\"}"
Enter fullscreen mode Exit fullscreen mode

Read it like a sentence:

  • @name gives the request a human name.
  • @tags smoke puts it in the smoke suite.
  • @expect status 200 means anything else is a failure.
  • @expect json .token exists checks the body with a jq expression.
  • @capture TOKEN=.token saves the token for every request that comes after.

And here is the best part: to the shell, all of that is just comments. The file still runs on its own, with or without Sankh:

set -a; . environments/dev.env; set +a
sh auth/01-login.sh
Enter fullscreen mode Exit fullscreen mode

No lock-in, no export button, no proprietary format. If Sankh vanished tomorrow, you'd still own a folder of perfectly good curl commands. That's my favourite feature, and I didn't even have to write code for it.

Chapter 4: A collection is just a folder

Put a few of those files together and you have a collection:

petstore/
  sankh.toml            # optional: name, default env, timeout
  .env.example          # what secrets you need (no values)
  .env.local            # your actual secrets (gitignored)
  environments/
    dev.env
    ci.env
  auth/
    01-login.sh
  pets/
    01-list.sh
    02-create.sh
    03-get.sh
    04-delete.sh
    05-get-deleted.sh
Enter fullscreen mode Exit fullscreen mode

The numeric prefixes set the run order. environments/dev.env holds the boring, shareable values:

BASE_URL=http://localhost:4010
PET_USER=demo
Enter fullscreen mode Exit fullscreen mode

And the password? It goes in .env.local, which is gitignored. Secrets in git are like glitter: you never fully get rid of them.

Chapter 5: Chaining, or "the token relay race"

APIs are rarely one call. You log in, create something, fetch it, delete it, then check it's really gone. Each step hands something to the next, like runners passing a baton.

The create step catches two batons at once, the new pet's id from the body and a request id from a response header:

#!/usr/bin/env bash
# @name Create pet
# @description Creates a pet and stores its id for the next steps.
# @tags smoke
# @expect status 201
# @expect json .name == "Rex"
# @expect json .tags contains "good-boy"
# @capture PET_ID=.id
# @capture REQUEST_ID=header X-Request-Id
curl -sS "$BASE_URL/pets" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"Rex","tags":["good-boy"]}'
Enter fullscreen mode Exit fullscreen mode

The next request just uses $PET_ID like any shell variable:

#!/usr/bin/env bash
# @name Get pet
# @expect status 200
# @expect json .id | tostring == "$PET_ID"
# @expect json .status matches "^(available|pending|sold)$"
curl -sS "$BASE_URL/pets/$PET_ID" \
  -H "Authorization: Bearer $TOKEN"
Enter fullscreen mode Exit fullscreen mode

And because a good test suite also checks the sad paths, the last step expects a 404:

#!/usr/bin/env bash
# @name Deleted pet is gone
# @tags negative
# @expect status 404
# @expect json .error == "not found"
curl -sS "$BASE_URL/pets/$PET_ID" \
  -H "Authorization: Bearer $TOKEN"
Enter fullscreen mode Exit fullscreen mode

Without any @expect status, a request passes on any 2xx. Once you write @expect status 404, a 404 is a success and a 200 is a failure. Finally, a tool that celebrates things going missing.

Chapter 6: Blow the conch

Sankh (शंख, "shankh") is the conch shell blown to announce a beginning. Here's what blowing it looks like:

$ sankh trust examples/petstore
$ sankh run examples/petstore
sankh · petstore · env dev (6 requests)
✓ Log in  200  12ms  auth/01-login.sh
    captured TOKEN=***
✓ List pets  200  4ms  pets/01-list.sh
✓ Create pet  201  5ms  pets/02-create.sh
    captured PET_ID=1
    captured REQUEST_ID=req-1
✓ Get pet  200  3ms  pets/03-get.sh
✓ Delete pet  204  3ms  pets/04-delete.sh
✓ Deleted pet is gone  404  3ms  pets/05-get-deleted.sh

6 passed  41ms
Enter fullscreen mode Exit fullscreen mode

Notice TOKEN=***. Any variable whose name contains TOKEN, SECRET, KEY, PASSWORD, AUTH or COOKIE is masked everywhere: terminal output, reports and the UI. Screenshots of your terminal are now safe to post on the internet, and so is your pride.

Now let's break something on purpose. Say someone renames the dog and changes the assertion to @expect json .name == "Fluffy":

✗ Create pet  201  5ms  pets/02-create.sh
    json .name == "Fluffy": expected .name == "Fluffy", got "Rex"
    │ {"id":1,"name":"Rex","status":"available","tags":["good-boy"]}
Enter fullscreen mode Exit fullscreen mode

It tells you which assertion failed, what it expected, what it got, and shows the body. Captures only run when every assertion passes, so a broken step never passes a bad PET_ID down the line. The run exits with code 1, which is exactly what CI wants to hear.

Chapter 7: CI, the part where Friday is saved

Remember the CI job that needed a different tool, login and environment? Here's the whole thing now:

- run: curl -fsSL https://sankh.dev/install.sh | sh
- run: sankh run . --env ci --tag smoke --trust --report junit
  env:
    PET_PASSWORD: ${{ secrets.PET_PASSWORD }}
Enter fullscreen mode Exit fullscreen mode

One binary. Secrets come from CI, because the process environment beats the env files. --tag smoke runs just the smoke suite. --report junit gives your CI a test report it already knows how to display.

The exit codes mean something, too:

Exit code Meaning
0 Everything passed
1 An assertion, a capture or a request failed
2 Usage or configuration error
3 The collection isn't trusted

And that pull request with the 1,400-line JSON diff? Now it looks like this:

 # @expect status 201
 # @expect json .name == "Rex"
+# @expect json .status == "available"
Enter fullscreen mode Exit fullscreen mode

You can review that. You can even review it on your phone, in a queue, while pretending to listen to someone.

Chapter 8: "Wait, it runs shell scripts from a git repo?"

Yes. And that should make you a little nervous, so Sankh is nervous on your behalf.

  • Trust first. A freshly cloned collection refuses to run until you sankh trust it. In a git repository the trust is tied to the HEAD commit, so after a git pull you review and trust again. Nobody sneaks a rm -rf into 03-get.sh.
  • The local UI is locked down. sankh serve binds to 127.0.0.1, rejects foreign Host headers (so no DNS rebinding tricks) and blocks cross-origin calls. Any other address needs a token.
  • No telemetry. The only network requests Sankh makes are the ones in your files.

Chapter 9: For people who like buttons

Not everyone wants to live in the terminal, and that's fine:

sankh serve examples/petstore    # http://localhost:4747
Enter fullscreen mode Exit fullscreen mode

You get a small web UI with a file tree, a form editor and a raw editor, an environment picker (with a Manage button to edit env files) and live results. The server runs the requests, not the browser, so there's no CORS drama.

There's also a desktop app for macOS, Windows and Linux, which is the same UI in a native window. And a Scratch collection for "let me just try this one call" moments, with Copy to… for when that one call turns out to be important.

Chapter 10: Bringing the old collection along

That Postman collection from Chapter 1? It doesn't have to be left behind:

sankh import postman Petstore.postman_collection.json --env dev.postman_environment.json
Enter fullscreen mode Exit fullscreen mode

Folders become directories and requests become numbered .sh files. {{baseUrl}} becomes ${BASE_URL}. Common test scripts translate directly:

Postman Sankh
pm.response.to.have.status(201) # @expect status 201
pm.environment.set("token", pm.response.json().token) # @capture TOKEN=.token

Anything Sankh can't translate is kept as comments and listed in an import report, because Sankh doesn't run JavaScript. Secrets are never written to disk; they're listed in .env.example, and .env.local is created with placeholder values for you to fill in.

Epilogue: How it works (it's almost embarrassing)

Sankh is written in Rust, and the trick at its core is small. It never modifies your file. It defines a curl shell function that adds -o, -D and -w '%{json}' to record the body, headers and timing, then sources your file. When curl finishes, Sankh checks your annotations against what it recorded.

That's why every curl flag you already know just works. Sankh didn't reinvent HTTP. It just started taking notes.

Try it

curl -fsSL https://sankh.dev/install.sh | sh
sankh init my-api
sankh trust my-api
sankh run my-api
Enter fullscreen mode Exit fullscreen mode

The annotation format is young, and @expect header, @expect time, @retry and @depends are next on the list. If you try it, tell me what's missing, and what made you smile. I'll take both.

Now go close that laptop. It's Friday. 🐚

Top comments (4)

Collapse
 
nikolas_dimitroulakis_d23 profile image
Nikolas Dimitroulakis •

How does sankh trust behave on a teammate's PR: does trust reset on every new commit hash, or only when a .sh file actually changes? Tying trust to git is the right call when the request files are themselves executable. I work on Voiden, an open-source API client where requests are Markdown files in the repo, and "the file still runs without the tool" is a property I'd like more API tooling to copy.

Collapse
 
rp01 profile image
Ramananda Panda •

Thanks! Right now it's the blunt version: trust is pinned to the HEAD commit. When you check out a teammate's PR branch, HEAD moves and sankh refuses to run until you sankh trust again, even if the PR never touched the collection. Switching back to main asks again too, because only one SHA is stored per folder.

I chose this on purpose for v1. It's simple, easy to reason about, and it fails closed. Also, the .sh files aren't the only things that run: sankh.toml, env files and hooks all affect what gets executed. So "only re-trust when a .sh changes" would be too narrow.

The better version I'd like to ship is a scoped diff. Keep the trusted SHA, and when HEAD moves, check git diff ..HEAD -- . If nothing under the collection changed, trust carries forward. If something did, show exactly those files when asking you to re-trust. That turns re-trusting into a real review step instead of a speed bump.

And yes, "the file still runs without the tool" is the property I care most about too. Nice to see Voiden doing the same with Markdown.

Collapse
 
nikolas_dimitroulakis_d23 profile image
Nikolas Dimitroulakis •

Failing closed is fair for a v1, an extra prompt beats running a hook you never read. The scoped diff sounds like the right next step. Will the re-trust prompt show the actual diff or just the file names?

Thread Thread
 
rp01 profile image
Ramananda Panda •

Good question. I'd ship the file list first, then the actual diff right after. The trusted SHA is already stored, so the diff is just git diff ..HEAD -- . A shell script can't really be reviewed by filename alone, so showing the changed lines is the point of the whole feature. Thanks for pushing on it.