Skip to content

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:

  • createGraphQLBinding from @identity-flow/binding-graphql is 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.