Call a GraphQL service through a binding
A workflow reaches an external system through a binding: its named access to that system. In this recipe a binding reads an employee from a GraphQL directory service, and the test replaces the binding so no network is involved.
Save the two files as graphql-binding.workflow.ts and
graphql-binding.workflow.case.ts.
The host deploys only a workflow file’s default export, so one file holds one
workflow; the test imports the same definition by name.
import process from 'node:process';
import * as v from '@identity-flow/sdk/valibot';import { createGraphQLBinding, gql } from '@identity-flow/binding-graphql';import { NonRetryableError, defineBinding, defineWorkflow } from '@identity-flow/sdk';
export interface Employee { readonly displayName: string; readonly managerId: string;}
export interface AcmeDirectory { employee(employeeId: string): Promise<Employee | null>;}
const EMPLOYEE = gql` query Employee($employeeId: ID!) { employee(id: $employeeId) { displayName managerId } }`;
// Headers are read for every request, so the credential never becomes part of// workflow state, where it would be written to an event and stay there.const acmeGraphQL = createGraphQLBinding({ url: process.env['ACME_GRAPHQL_ENDPOINT'], headers: () => ({ authorization: `Bearer ${process.env['ACME_GRAPHQL_TOKEN'] ?? ''}` }),});
/** The connector adapts the protocol; the binding exposes what the workflow asks for. */export const AcmeDirectory = defineBinding('acme-directory', (): AcmeDirectory => { const client = acmeGraphQL(); return { async employee(employeeId) { const { employee } = await client.request<{ employee: Employee | null }>(EMPLOYEE, { employeeId, }); return employee; }, };});
export const notifyManager = defineWorkflow( { name: 'notify-manager', version: '1.0.0', draft: false, schema: v.object({ employeeId: v.string() }), }, async (flow) => { const directory = flow.use(AcmeDirectory);
return await flow.do('load employee', { retries: 3 }, async () => { const employee = await directory.employee(flow.params.employeeId); if (!employee) { throw new NonRetryableError(`Unknown employee ${flow.params.employeeId}`); } return employee; }); },);
// The host deploys only the default export, so one file holds one workflow. The// test imports the named export; both are the same definition.export default notifyManager;The file has two layers:
createGraphQLBindingfrom@identity-flow/binding-graphqlis the connector, the protocol implementation. It knows GraphQL and returns a client.defineBinding('acme-directory', …)is the binding. It wraps the client in the interface the workflow uses,employee(employeeId), and gives it the name a test replaces.
Keep that split when you copy the file. The query, the response shape and the
transport stay in this file; the workflow sees only AcmeDirectory.
headers is a function, so the connector reads the credential when it sends a
request, not when the module loads. The token never becomes part of the
workflow’s recorded data.
Set ACME_GRAPHQL_ENDPOINT in every environment. If it is not set, url is
undefined, and the connector falls back to the GRAPHQL_ENDPOINT variable and
then to http://localhost:4000/-/graphql. Unlike headers, the URL is read
once, when the module loads.
retries: 3 allows three attempts in total with the default delay. An unknown
employee is a NonRetryableError, because asking again returns the same answer.
The workflow ends after loading the employee; a real one would continue, for
example with a dialog for the manager.
import { workflowTest } from '@identity-flow/testing';import { expect, vi } from 'vitest';
import { AcmeDirectory, notifyManager } from './graphql-binding.workflow';
const test = workflowTest({ workflow: notifyManager, accounts: { operator: { providerId: 'acme', principals: ['role:directory-reader'] } },});
test('reads the directory through a fixture, never over the network', async ({ flow, accounts, fixture,}) => { const employee = vi .fn<AcmeDirectory['employee']>() .mockResolvedValue({ displayName: 'Requesting employee', managerId: 'e-2042' }); fixture(AcmeDirectory, { employee });
const instance = await flow.actAs(accounts.operator).start({ employeeId: 'e-2041' }); const completed = await flow.waitFor(instance).toBeCompleted();
expect(completed.data).toEqual({ displayName: 'Requesting employee', managerId: 'e-2042' }); expect(employee).toHaveBeenCalledExactlyOnceWith('e-2041');});
test('does not retry an employee the directory does not have', async ({ flow, accounts, fixture,}) => { const employee = vi.fn<AcmeDirectory['employee']>().mockResolvedValue(null); fixture(AcmeDirectory, { employee });
const instance = await flow.actAs(accounts.operator).start({ employeeId: 'e-9999' });
await expect.poll(async () => (await flow.observe(instance)).instance.status).toBe('ERRORED'); expect(employee).toHaveBeenCalledOnce();});fixture(AcmeDirectory, { employee }) replaces the binding for this test. The
production factory is never called and no network request is sent. The
replacement is a vi.fn typed from the AcmeDirectory interface, so a change
to that interface fails the type check (npx tsc --noEmit). Vitest alone
does not check types.
Run the test with the Workflow Test distribution, as described in Before you start.
Next: Bindings and connectors for the rules a binding follows, and Outgoing GraphQL for every connector option.