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.

LayerCan mutate?Visible to SnapAPI?
SnapAPI variablesYes, through the DSLYes
Python module stateYesNo
HTTP executed by PythonYes, externallyNo

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.

Plugin functions must be thread-safe when using --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

QuestionAnswer
Allowed returnsString, number, bool, object, array. Tuple → array. UTF-8 bytes → string.
NoneFails the test: returned None.
ExceptionsFail the test (or the suite, if CALL is above TEST). No traceback.
AsyncNot supported. Use a synchronous function.
HTTP from PythonInvisible to SnapAPI. Not a second HTTP runner. See three kinds of state.
When plugins loadOnce per process. Modules are cached. Each suite builds a name registry from extensions/ / snapapi.yaml / --plugin.
--workersThreads in one process. Plugin functions must be thread-safe. Module-level mutable state is shared. SnapAPI variables are copied per worker.
SnapAPI variablesFunctions 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.
Plugin functions return values. They don’t mutate 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.