Chaining requests
Use these recipes to connect requests in one collection. Replace
api.example.com and the example fields with your API. For an ordered workflow,
start with captures and the
Collection Runner. Use an awaited saved or
direct request call when a pre/post script needs the result immediately.
Saved child requests run their complete lifecycle, including inherited blocks. Each phase uses collection → outermost folder → nearest folder → request. Put a login call on the dependent request: a collection-wide login hook also runs on the login request itself and can trigger cycle protection.
Share data across requests
Section titled “Share data across requests”Use this when several requests in one collection run need the same generated test data. This two-request workflow creates a customer, then looks it up by email. It needs no environment inputs. Put both files in your collection and ensure Create customer precedes Find customer in collection order.
Save the first request as customers/create.yml:
name: Create customermethod: POSTurl: https://api.example.com/customersbody_type: jsonbody: '{"name":"Cookbook Customer"}'scripts: pre: |- const email = "cookbook+" + noodle.crypto.randomBytes(8, "hex") + "@example.com"; noodle.run.set("customer_email", email); noodle.request.body.setJson({ ...noodle.request.body.json(), email });The first request sets the shared value and writes it directly into its body.
Save the later request as customers/find.yml. It can use ordinary substitution:
name: Find customermethod: GETurl: https://api.example.com/customersparams: - name: email value: $customer_emailRun them together with the CLI or the TUI Runner:
noodle collection run ./api customers/create customers/findSelected requests execute in collection order. The second request sends the same
email as the first. noodle.run.get("customer_email") reads it from a later script;
noodle.run.unset("customer_email") removes it from the transient scope.
noodle.run.set() commits when the script succeeds, before HTTP. The email remains
available even if customer creation later fails, so add suitable assertions and
use --fail-fast if later requests depend on a successful creation.
The value disappears when the collection run ends. Separate manual sends and
request run calls have isolated scopes, so they cannot share this value. Do
not put $customer_email in the first request: its substitution would run before
the script creates the variable. Use a capture
when you need a server-generated customer ID instead.
Saved requests and captured values
Section titled “Saved requests and captured values”Create auth/login.yml for your API’s login endpoint, with its required
credentials configured through authentication
and secrets. It should return
JSON containing a non-empty string token. Save this dependent request as
users/profile.yml. The child runs its scripts, captures, assertions, and tests:
name: Log in and fetch a profilemethod: GETurl: https://api.example.com/mescripts: pre: |- const login = await noodle.runRequest("auth/login"); const token = login.json().token; if (typeof token !== "string" || !token.trim()) { throw new Error("Login must return a non-empty token"); } noodle.run.set("TOKEN", token); noodle.request.auth.setBearer(token);The outgoing profile request uses the returned bearer token. A failed child or missing token stops the pre script and skips the profile request.
Saved child captures and writes are readable with noodle.run.get. Later child
calls see these and the parent’s staged values. Failed children roll back their
variables; a failed parent rolls back the combined variables. Nested persistence
is transient. On a manual send, the parent may explicitly persist with
noodle.run.set("TOKEN", token, { persist: "secret" }). Collection runs suppress
persistence. HTTP and cookie effects cannot be rolled back.
Log in once and reuse the token
Section titled “Log in once and reuse the token”Use a folder pre script for APIs with a custom login endpoint. Configure
LOGIN_USER in the selected environment and store LOGIN_PASSWORD as a
secret. Save this as
auth/login.yml, outside the folder that will use the shared login hook:
name: Get a session tokenmethod: POSTurl: https://api.example.com/sessionscripts: pre: |- noodle.request.body.setJson({ username: noodle.env.get("LOGIN_USER"), password: noodle.env.get("LOGIN_PASSWORD"), });The login must return JSON with a non-empty token and a numeric expires_in
in seconds, such as {"token":"example-token","expires_in":3600}. Merge this
into users/folder.yml:
scripts: pre: |- let token = noodle.run.get("SESSION_TOKEN"); let expiresAt = noodle.run.get("SESSION_EXPIRES_AT"); if (typeof token !== "string" || !token.trim() || !Number.isSafeInteger(expiresAt) || expiresAt <= noodle.time.now() + 30000) { const requestedAt = noodle.time.now(); const session = (await noodle.runRequest("auth/login")).json(); token = session.token; expiresAt = requestedAt + session.expires_in * 1000; if (typeof token !== "string" || !token.trim() || typeof session.expires_in !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt <= noodle.time.now() + 30000) { throw new Error("Login must return a token with more than 30 seconds remaining"); } noodle.run.set("SESSION_TOKEN", token); noodle.run.set("SESSION_EXPIRES_AT", expiresAt); } noodle.request.auth.setBearer(token);Requests under users/ inherit this hook. For example, save this as
users/profile.yml:
name: Fetch my profilemethod: GETurl: https://api.example.com/meThe first request logs in; later requests in the same collection run reuse the token while it has more than 30 seconds left. The margin allows time to send the next request. An expired or nearly expired token triggers another login. A failed login stops that request before HTTP. This does not automatically retry 401 responses from a revoked token.
Keep auth/login outside users/ so it does not inherit its own login hook.
The folder pre runs before the request pre, following the usual inherited
order. Set bearer auth directly here: $SESSION_TOKEN in the same request’s
saved auth would be substituted before this hook can create it.
Both cached values disappear at the end of the run. Separate manual sends and dataset rows have fresh scopes, so each logs in independently. For OAuth2, prefer the built-in authentication configuration.
Direct HTTP with fallback
Section titled “Direct HTTP with fallback”Use this when a lookup can legitimately return 404. Configure API_TOKEN as a
secret in the selected environment. This example fetches a profile before
sending a health request with its ID, or guest when the profile is absent:
name: Check health with an optional profilemethod: GETurl: https://api.example.com/healthscripts: pre: |- const token = noodle.env.get("API_TOKEN"); if (!token || !token.trim()) throw new Error("API_TOKEN is required"); try { const profile = await noodle.sendRequest({ url: "https://api.example.com/me", headers: { Authorization: `Bearer ${token}` }, timeout: 5000, }); noodle.run.set("USER_ID", profile.json().id); } catch (error) { if (!error.response || error.response.status !== 404) throw error; noodle.run.set("USER_ID", "guest"); } noodle.request.headers.set("X-User-ID", String(noodle.run.get("USER_ID")));Direct values are literal, with no substitution or inherited parent credentials.
For JSON uploads, set method: "POST", body: JSON.stringify(value), and
headers: { "Content-Type": "application/json" }. Both call APIs throw on HTTP
status 400 or higher, validation, or transport failures; saved calls also throw
on failed execution diagnostics. Errors expose response and diagnostics when
available. Caught failures remain visible in Results without failing the parent.
Await calls sequentially: one outstanding call per script, ten calls per top-level request, four nested levels, and a 30-second ancestor-bounded wall deadline. See the full contract.
Create a resource and fetch it
Section titled “Create a resource and fetch it”Use captures for a straightforward ordered workflow. The create endpoint must
return HTTP 201 and a JSON id. Save this as users/create.yml:
name: Create a cookbook usermethod: POSTurl: https://api.example.com/usersbody_type: jsonbody: '{"name":"Cookbook User"}'capture: created_user_id: value: body.idassert: - expression: status operator: equals value: 201Save the second request as users/get.yml:
name: Fetch the created usermethod: GETurl: https://api.example.com/users/$created_user_idassert: - expression: status operator: equals value: 200tests: |- test("fetched the created user", () => { expect(noodle.response.json().id).toEqual(noodle.run.get("created_user_id")); });Place Create a cookbook user before Fetch the created user in collection order, then run both with the shared scope:
noodle collection run ./api users/create users/get --fail-fastSelected requests run in collection order, not argument order. A failed creation
or capture prevents the dependent request from running because of --fail-fast.
On success, the fetched ID matches the captured ID. Separate manual sends do
not share this transient value; use a saved child call when you need both
operations within one manual send. Clean up test resources according to your API.
Compare list and detail responses
Section titled “Compare list and detail responses”Use this when a list and a detail endpoint must agree on the same record.
The list should return an array containing exactly one user with ID 7; both
endpoints should expose the same name and active values. This example uses
public endpoints and a stable test user that will not change between calls:
name: Compare a user with the list representationmethod: GETurl: https://api.example.com/users/7scripts: pre: |- const list = await noodle.sendRequest({ url: "https://api.example.com/users", timeout: 5000, }); if (list.status !== 200) throw new Error("User list must return HTTP 200"); const users = list.json(); if (!Array.isArray(users)) throw new Error("Expected a user array"); const matches = users.filter(user => user && user.id === 7); if (matches.length !== 1) throw new Error("Expected exactly one user with ID 7"); noodle.run.set("listed_user", matches[0]);tests: |- test("detail matches the listed user", () => { expect(noodle.response.status).toBe(200); const listed = noodle.run.get("listed_user"); expect(listed.name).toBeTypeOf("string"); expect(listed.active).toBeTypeOf("boolean"); expect(noodle.response.json()).toMatchObject({ id: listed.id, name: listed.name, active: listed.active, }); });The pre script fetches the list and retains the matching user for the test. A missing or duplicate match stops the detail request. The test allows extra detail fields while failing differences in the three compared fields. It searches only the returned list page, so select a page or filter that includes the test user. For protected endpoints, configure authentication on the saved request and supply the appropriate headers in the direct call too.
Verify idempotency with a repeated request
Section titled “Verify idempotency with a repeated request”Use a test API that supports Idempotency-Key for order creation. It must return
HTTP 201 with a positive integer id on creation, then HTTP 200 or 201 with the
same ID when the exact request is repeated with the same key. Configure
API_TOKEN as a secret in the selected environment:
name: Verify an order is created only oncemethod: POSTurl: https://api.example.com/ordersauth: type: bearer token: $API_TOKENbody_type: jsonbody: '{"product_id":"demo-product","quantity":1}'scripts: pre: |- noodle.request.headers.set("Idempotency-Key", noodle.crypto.randomBytes(16, "hex")); post: |- if (noodle.response.status !== 201) throw new Error("Creation must return HTTP 201"); const id = noodle.response.json().id; if (!Number.isSafeInteger(id) || id <= 0) { throw new Error("Creation must return a positive integer order ID"); } const repeated = await noodle.sendRequest({ url: noodle.request.url, method: "POST", headers: { Authorization: "Bearer " + noodle.env.get("API_TOKEN"), "Content-Type": "application/json", "Idempotency-Key": noodle.request.headers.get("Idempotency-Key"), }, body: noodle.request.body.text(), timeout: 5000, }); noodle.run.set("repeated_order", { status: repeated.status, id: repeated.json().id });tests: |- test("repeating the same key returns the same order", () => { const repeated = noodle.run.get("repeated_order"); expect([200, 201]).toContain(repeated.status); expect(repeated.id).toBe(noodle.response.json().id); });Each manual send starts a new experiment with a fresh key. Only the child call reuses that key and the exact prepared body; generating a new key for the child would test a different operation. Use an endpoint that accepts the POST directly without redirects, and preserve any other headers your API uses to define the operation’s identity.
A different returned ID or an unacceptable status fails. Matching IDs confirm the response contract for this repeat; they do not prove the absence of every server-side side effect. This creates a real test order, or more than one if the API ignores idempotency. Clean up the created resources according to your API. See idempotency keys for configuring retries of an existing operation.
Test conditional GET with an ETag
Section titled “Test conditional GET with an ETag”Use a public endpoint that returns an ETag on HTTP 200 and supports
If-None-Match. Choose a stable fixture: a resource changed between these two
calls can legitimately return HTTP 200 instead of 304.
name: Revalidate an unchanged catalogmethod: GETurl: https://api.example.com/catalogscripts: pre: |- const initial = await noodle.sendRequest({ url: noodle.request.url, timeout: 5000 }); if (initial.status !== 200) throw new Error("Initial catalog must return HTTP 200"); const etag = initial.headers.get("ETag"); if (typeof etag !== "string" || !etag.trim()) { throw new Error("Initial catalog must include an ETag"); } noodle.request.headers.set("If-None-Match", etag);tests: |- test("unchanged catalog returns 304 without a body", () => { expect(noodle.response.status).toBe(304); expect(noodle.response.text()).toBe(""); });The pre script first fetches the resource without a conditional header, then
sets If-None-Match on the outgoing saved request. Keep the returned ETag
unchanged, including quotes and any W/ prefix. Both calls must select the same
representation; if your API uses authorization or Accept headers, supply the
same values on both requests.
Missing ETags stop the saved request. A 200 response fails the test because
this fixture is expected to remain unchanged. Use text() for the empty 304
body and avoid inherited checks that require JSON from every response.
Create and clean up a test resource
Section titled “Create and clean up a test resource”Use this against a test API to create a resource, update it, verify the saved
fields, and delete it. Configure API_TOKEN as a secret in the selected
environment. The create endpoint must return HTTP 201 with a positive integer
id; update accepts PATCH, fetch returns HTTP 200, and delete returns HTTP 204.
name: Verify the user lifecyclemethod: POSTurl: https://api.example.com/usersauth: type: bearer token: $API_TOKENbody_type: jsonbody: '{"name":"Cookbook User"}'scripts: post: |- if (noodle.response.status !== 201) throw new Error("Creation must return HTTP 201"); const id = noodle.response.json().id; if (!Number.isSafeInteger(id) || id <= 0) { throw new Error("Creation must return a positive integer ID"); } const url = "https://api.example.com/users/" + id; const headers = { Authorization: "Bearer " + noodle.env.get("API_TOKEN"), "Content-Type": "application/json", }; let failure; try { await noodle.sendRequest({ url, method: "PATCH", headers, body: JSON.stringify({ name: "Updated Cookbook User" }), timeout: 5000, }); const saved = await noodle.sendRequest({ url, headers, timeout: 5000 }); const user = saved.json(); if (saved.status !== 200 || user.id !== id || user.name !== "Updated Cookbook User") { throw new Error("Fetched user must contain the updated fields"); } } catch (error) { failure = error; } finally { try { const deleted = await noodle.sendRequest({ url, method: "DELETE", headers, timeout: 5000 }); if (deleted.status !== 204) throw new Error("Deletion must return HTTP 204"); } catch (error) { console.error("Cleanup failed for user", id); failure ??= error; } } if (failure) throw failure;The post block succeeds only after verifying the update and deleting the new user. Direct calls need their own authorization header. These calls belong in post because scripted tests cannot send requests.
finally attempts deletion even when update or verification fails. A cleanup
failure logs the resource ID and fails the block; if an earlier step also
failed, its error is preserved. Inspect Results for the failed child call.
HTTP changes cannot be rolled back, and cleanup cannot be guaranteed after
cancellation, the script deadline, or a creation response without a usable ID.
Use the logged ID for manual cleanup when available.