Skip to content

How to make Requests in Hooks

Some Transactions need a resource that already exists. utils.request calls another Transaction to create one. A request a hook makes this way is a Seed.

utils.request is keyed by Selector — the same strings your hooks are anchored to — and resolved through the same catalog, so the keys your editor completes and the strings resolved at run time are the same strings by construction.

utils.request(
selector, // the Transaction to initiate
args?, // body, headers, query, path, cookies — overlaid on the generated request
options?, // runHooks, authorize
);

There is no forStatusCode: the Selector already carries the status you asked for.

The Selector says what you asked for, not what you get

Section titled “The Selector says what you asked for, not what you get”

A Selector names the Transaction you want to initiate. Whether it happens is the server’s decision, so the answer is a union of every response the operation declares — narrowed by statusCode, and by mediaType where one status declares several.

import { beforeEach } from '@thymian/hooks';
export const seedLaunch = beforeEach(
'GET /launches/{id} -> 200 (application/json)',
async (request, ctx, utils) => {
const created = await utils.request(
'POST /launches (application/json) -> 201 (application/json)',
{ body: { missionName: 'Apollo 11' } },
{ authorize: true },
);
if (created.statusCode !== 201) {
utils.skip('could not create a launch to read back');
}
// Narrowed to the 201 body, so `id` is checked — and a field that stops
// existing in the description is a compile error here.
utils.setPathParam('id', created.body.id);
},
);

Branching is how you get at a body: outside a narrowing check, created.body is the union of every declared body, and only what they all have is readable.

A hook that ignores the response still compiles and still runs. Nothing crashes, and nothing is silently wrong either — the run keeps its own account of what happened:

  • If the Seed was answered with another declared status, the value comes back truthfully and your hook carries on with it. If the Transaction then cannot be executed — usually because the id you needed was never created — thymian sampler check reports it as skipped and names the Seed and the status it actually got.
  • If the Seed was answered with a status the description never declares, utils.request throws UndeclaredResponseError — there is no union member for it to be. You may catch it; if you do not, the Transaction is skipped, again naming the Seed, and the run continues.
↷ GET /launches/{id} -> 200 (application/json)
The seed "POST /launches (application/json) -> 201 (application/json)" was answered with 500, which the specification does not declare for that operation.
Branch on the seed's status code, or call utils.skip to end this transaction deliberately.

To react to an off-spec answer yourself, catch it:

import { beforeEach, UndeclaredResponseError } from '@thymian/hooks';
export const seedLaunch = beforeEach(
'GET /launches/{id} -> 200 (application/json)',
async (request, ctx, utils) => {
try {
const created = await utils.request(
'POST /launches (application/json) -> 201 (application/json)',
{ body: { missionName: 'Apollo 11' } },
);
utils.setPathParam('id', created.body.id);
} catch (error) {
if (!(error instanceof UndeclaredResponseError)) {
throw error;
}
utils.skip(`the API answered ${error.statusCode} while seeding`);
}
},
);

UndeclaredResponseError carries selector, statusCode, headers and the parsed body, so an off-spec answer is inspectable rather than merely fatal.

The generated sample plus the target’s own hooks already produce a complete request. args is an overlay on it, so you write only what has to differ:

  • Parameter groups merge per key. A header you set replaces that header; every other header the description produced survives.
  • The body deep-merges. Objects recurse, arrays and primitives replace whole, and null is a value rather than an absence.
// The generated body is { missionName: 'Generated', crew: ['A', 'B'], pad: { id: 1, name: 'LC-39A' } }
await utils.request(selector, {
body: { crew: ['Ada'], pad: { name: 'SLC-40' } },
});
// Sent: { missionName: 'Generated', crew: ['Ada'], pad: { id: 1, name: 'SLC-40' } }

Everything is optional, so utils.request(selector) sends the generated request unchanged.

By default a nested request runs the target’s own beforeEach → authorize → afterEach pipeline, so seeding behaves like the real run rather than like a second, quieter client. Your authorize hook applies to it; so does any beforeEach that shapes it. It is also sent to the origin the run is talking to, so a --target-url covers your Seeds too.

await utils.request(selector, args, { runHooks: false });

runHooks: false sends the generated request as-is. Use it when you want the raw request, and when you need to break a cycle.

Two hooks that seed each other would recurse until the stack gave out. Instead the sampler refuses, and prints the chain:

A cross-endpoint request would re-enter "GET /launches/{id} -> 200 (application/json)", which is already running.
Try this:
* The chain is:
"GET /launches/{id} -> 200 (application/json)"
→ "POST /launches (application/json) -> 201 (application/json)"
→ "GET /launches/{id} -> 200 (application/json)"
* Break the cycle, or pass `{ runHooks: false }` to seed from the raw request
without running the target's hooks.

The chain is printed because A → B → A is usually a mistake three files apart, and the middle of it is where the fix goes.

Nothing is sent before the refusal.

If one resource is shared by every Transaction, beforeAll is cheaper than seeding per request — and its returned closure is how that resource gets cleaned up.

import { beforeAll } from '@thymian/hooks';
export const seedOnce = beforeAll(async (utils) => {
const created = await utils.request(
'POST /launches (application/json) -> 201 (application/json)',
{ body: { missionName: 'Shared launch' } },
{ authorize: true },
);
if (created.statusCode !== 201) {
// A throw in `beforeAll` ends the run: without this resource, nothing that
// depends on it can be executed as described.
throw new Error('the shared launch could not be created');
}
const id = created.body.id;
return async () => {
await utils.request(
'DELETE /launches/{id} -> 204',
{ path: { id } },
{ authorize: true },
);
};
});