Skip to content

Call a GraphQL service through a binding

Your workflow needs an employee Binding fixture in tests GraphQL directory in production reads through queries
Your workflow needs an employee Binding fixture in tests GraphQL directory in production reads through queries
The workflow reads an employee through a named binding; the test replaces the binding, so no network is involved.

Read an employee from an external GraphQL directory through a binding, the workflow’s named access to that system, and test the workflow without network calls. Configure the endpoint and credentials before deploying it.

Save the two files as graphql-binding.workflow.ts and graphql-binding.workflow.case.ts.

After saving both files, run the test, then adapt the example to your system.

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.
WORKFLOW TEST Workflow flow.use(…) Factory your function Client cached, reused Fixture same name External service e.g. GraphQL first use creates on miss calls in a step replaces sends request
WORKFLOW TEST Workflow flow.use(…) Factory your function Client cached, reused Fixture same name External service e.g. GraphQL first use creates on miss calls in a step replaces sends request
A step calls the cached client, which the factory creates on first use and a fixture with the same name replaces in a Workflow Test.

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.

headers: () => ({ authorization:
  1. read per request

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.

url: process.env['ACME_GRAPHQL_ENDPOINT'],
  1. read once at load

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.

fixture(AcmeDirectory, { employee });
  1. replaces this binding

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.