Tooling
CALL
When SnapAPI doesn’t know how to compute one value, assign it from a Python function. The .sapi file still reads as HTTP. It does not name Python files.
Assignment, then HTTP
SET: PAYLOAD snapapi
CALL: signature = crypto.generate_signature(${PAYLOAD}, ${HMAC_SECRET})
TEST: Signed request
GET: /users
HEADER: X-Signature: ${signature}
EXPECT: status == 200
Mental model: CALL: variable = function(arguments) → Python runs → return value → ${variable}. There is no IF, FOR, or TRY.
A CALL should behave like result = function(arguments). Nothing more. No SnapAPI context object, no mutating test state, no secret assertions, no secret SnapAPI requests.
Where the function lives
Put modules in extensions/ next to the suite (or a parent folder). SnapAPI loads public functions automatically. Optional snapapi.yaml lists them:
extensions:
- extensions.crypto
extensions/
└── crypto.py
def generate_signature(payload, secret):
...
return digest
Arguments are explicit. Pass ${PAYLOAD}, not a hidden SnapAPI context. In the repo: examples/plugins/ (same hello mock as quick start).
snapapi mock examples/hello/mock.json --port 8765
snapapi examples/plugins/signed.sapi -D HMAC_SECRET=dev
Namespaces
If two modules define the same function name, the bare name is an error. Qualify it:
CALL: token = auth.generate_token()
CALL: signature = crypto.generate_signature(${PAYLOAD}, ${HMAC_SECRET})
Unique names can omit the module: CALL: user = generate_user().
Return values
A string or number becomes ${signature} as you’d expect. An object or array is kept structured, so this works:
CALL: user = testdata.generate_user()
POST: /users
BODY: ${user}
Return a string, number, bool, object, or array. None fails the test. Tuples become arrays. Bytes must be UTF-8 text.
When a function fails
An exception fails that test. SnapAPI prints a SnapAPI error, not a Python traceback:
CALL FAILED
Function: crypto.generate_signature
Test: Create Payment
Error: Invalid secret
Same shape for None, a disallowed return type, or an async def. Suite-level CALL stops the run before tests.
Computation, not the system under test
CALL is for computation, not for driving the system under test. If the HTTP isn’t in the .sapi file, SnapAPI did not run it.Good
CALL: signature = crypto.generate_signature(${PAYLOAD}, ${SECRET})
CALL: user = testdata.generate_user()
CALL: expiry = crypto.expiry(${TTL})
Compute a value. Assign it. Then GET / POST as usual.
Bad
CALL: response = api.create_user()
CALL: response = http_get("/users")
That is not a replacement for POST: /users. The request will not appear in the report, retries, auth, or EXPECT. You bypassed SnapAPI’s HTTP engine. That is the boundary, not a bug.
Three kinds of state
Keep these separate. Mixing them is how plugins become impossible to debug.
| Layer | Can mutate? | Visible to SnapAPI? |
|---|---|---|
| SnapAPI variables | Yes, through the DSL | Yes |
| Python module state | Yes | No |
| HTTP executed by Python | Yes, externally | No |
SnapAPI variables
CALL: user = generate_user()
BODY: ${user}
Owned by SnapAPI. Test-scoped (copied per --workers worker). The only way a plugin changes them is by returning a value into the CALL variable.
Plugin state
cache = {}
def generate_token():
...
Python process state. Module globals are shared across tests and workers. SnapAPI does not isolate them.
HTTP from Python
requests.get(...)
Outside SnapAPI’s execution model. No EXPECT, SAVE, retries, auth, or reports. Don’t drive the API under test from a plugin.
--workers. Module-level mutable state is shared between workers. A global token = … will get stomped. SnapAPI will not magically isolate Python modules; if you need process isolation later, that can be a runner change without touching the DSL.Contract
| Question | Answer |
|---|---|
| Allowed returns | String, number, bool, object, array. Tuple → array. UTF-8 bytes → string. |
None | Fails the test: returned None. |
| Exceptions | Fail the test (or the suite, if CALL is above TEST). No traceback. |
| Async | Not supported. Use a synchronous function. |
| HTTP from Python | Invisible to SnapAPI. Not a second HTTP runner. See three kinds of state. |
| When plugins load | Once per process. Modules are cached. Each suite builds a name registry from extensions/ / snapapi.yaml / --plugin. |
--workers | Threads in one process. Plugin functions must be thread-safe. Module-level mutable state is shared. SnapAPI variables are copied per worker. |
| SnapAPI variables | Functions return a value. They do not receive a context and cannot assign ${token} themselves. Object arguments are copied, so mutating them does not change SnapAPI state. |
CALL: token = auth.generate_token() is the model. A hidden context["token"] = … is not.The function must not skip steps, issue the HTTP under test, or catch EXPECT. If you need that, keep pytest. See When SnapAPI is not for you.
--plugin path.py still loads an extra module (repeatable). Prefer extensions/ so the suite never mentions a path. Listeners wrap the run; they don’t compute this value. The playground can’t load Python — use the CLI.