Skip to content

Testing an API with the Sampler

This tutorial takes you from a bare checkout to a suite of typed hooks, using the Space Launch System API from the Thymian demo project.

By the end of this tutorial, you’ll be able to:

  • Run Thymian against an API with nothing set up
  • Read the request Thymian would send for any operation
  • Write a hook that authenticates, seeds data and asserts
  • Turn on editor autocomplete and a type gate for your hooks
  • Keep CI honest when the API description changes
  • Node.js 22+ installed
  1. Clone the demo repository

    Terminal window
    git clone https://github.com/thymianofficial/thymian-demo.git
  2. Install dependencies

    Terminal window
    npm install
  3. Start the API

    Terminal window
    npm run dev
  4. In another terminal, check you can run Thymian

    Terminal window
    npx thymian --version

There is no setup step. Run:

Terminal window
npx thymian test

Thymian reads the API description, projects a request for every Transaction in it, sends them, and checks the responses against its rules.

Most cases will be skipped with received 401. The Space Launch System API requires authentication and nothing has supplied any yet.

Before changing anything, see what Thymian is actually sending. Every Transaction has a Selector — its address:

METHOD path [(requestMediaType)] -> status [(responseMediaType)]
Terminal window
npx thymian sampler show 'GET /astronauts -> 200 (application/json)'
{
"authorize": true,
"cookies": {},
"headers": {
"accept": "application/json"
},
"method": "get",
"origin": "http://localhost:3000",
"path": "/astronauts",
"pathParameters": {},
"query": {
"limit": 0,
"offset": 0
}
}

"authorize": true is Thymian reading the security requirement out of the description. It means “this request wants credentials” — and no hook has offered any yet.

Get a Selector slightly wrong and Thymian tells you the ones that exist:

Terminal window
npx thymian sampler show 'GET /astronaut -> 200 (application/json)'
No transaction matches the selector "GET /astronaut -> 200 (application/json)".
Try this:
* Check the path against the loaded API description — no transaction with that path is loaded.

Hooks live under .thymian/sampler/hooks/. Create the directory and a file:

Terminal window
mkdir -p .thymian/sampler/hooks
.thymian/sampler/hooks/auth.ts
import { authorize } from '@thymian/hooks';
export const credentials = authorize((request) => {
const token = Buffer.from('sarah.mitchell@space.org:starlight').toString(
'base64',
);
request.headers['authorization'] = `Basic ${token}`;
});

Run the tests again:

Terminal window
npx thymian test

The 401 skips largely disappear. Two things made that work without any setup:

  • @thymian/hooks resolves through the plugin itself, so a hook file runs with nothing installed beside it and nothing generated.
  • The authorize hook ran only where the description says credentials are wanted. GET /astronauts -> 401 — the negative case — deliberately did not get them, so it still returns 401 and still passes.

Some Transactions need a resource that exists. GET /astronauts/{id} -> 200 is sent with a generated id that matches nobody.

Create one first, and read it back:

.thymian/sampler/hooks/seed.ts
import { beforeEach } from '@thymian/hooks';
export const seedAstronaut = beforeEach(
'GET /astronauts/{id} -> 200 (application/json)',
async (request, ctx, utils) => {
const created = await utils.request(
'POST /astronauts (application/json) -> 201 (application/json)',
{
body: {
name: 'Seeded ' + utils.randomString(6),
email: utils.randomString(8) + '@space.org',
password: 'hunter2',
role: 'Commander',
},
},
// A nested request carries its own target's authorize flag, not the
// caller's — so say so explicitly.
{ authorize: true },
);
if (created.statusCode === 201) {
utils.setPathParam('id', created.body.id);
}
},
);

utils.request is addressed by Selector, the same strings you have been using, and by default it runs the target’s own hooks — so the nested POST is authorized by the hook you wrote in Step 4, exactly as the real run would be.

Everything so far worked with plain strings. Now let the compiler check them:

Terminal window
npx thymian sampler init
  • Directory.thymian/sampler/
    • Directorygenerated/
      • request-types.d.ts Endpoints, keyed by Selector, plus the spec-derived unions
      • hooks-api.d.ts the @thymian/hooks surface, typed against them
    • Directoryhooks/
      • auth.ts
      • seed.ts
    • tsconfig.json scaffolded once — yours from here on

init also prints one thing only you can do: add .thymian/sampler to the exclude list of your project’s own tsconfig.json, so the sampler’s tsconfig is the one that governs your hooks.

Commit generated/. Those files are the record of what your API description said, and the baseline the drift gate compares against.

Now your editor completes Selectors, and a mistake is a compile error at the line that made it:

.thymian/sampler/hooks/broken.ts
import { beforeEach } from '@thymian/hooks';
export const typo = beforeEach({ path: '/astronaut' }, () => {});
error TS2345: Argument of type '{ path: "/astronaut"; }' is not assignable to
parameter of type 'HookTarget'. Did you mean '"/astronauts"'?

Delete that file again before continuing.

One hook can target a set of Transactions with a filter, including paths added to the description later:

.thymian/sampler/hooks/observe.ts
import { afterEach } from '@thymian/hooks';
export const observeLaunches = afterEach(
{ path: '/launches/**', not: { statusClass: '4XX' } },
(response, ctx, utils) => {
utils.info(`launches subtree responded ${response.statusCode}`);
},
);

* is exactly one path segment and a trailing ** is one or more — so /launches/** is everything under /launches, not /launches itself.

A glob that matches nothing fails the run and tells you which paths do exist, so a typo cannot quietly switch a hook off.

Two commands, and one rule.

Terminal window
npx thymian sampler validate

validate regenerates the type surface in memory, compares it with what you committed, and type-checks your hooks against your description as it is now:

  • everything matches and compiles → silent
  • the committed types are behind, but every hook still compiles → a warning to run sampler sync
  • a hook no longer compiles → breaking drift, named at the hook’s own line
Terminal window
npx thymian sampler sync --check

--check writes nothing and exits non-zero if regeneration would change the committed files. Put it in CI.

A description-only edit, and a pure reordering of your document, are not drift — the comparison ignores comments and formatting.