Skip to content

Bindings and connectors

Workflow flow.use Binding acme-directory Connector GraphQL External system your directory calls in a step uses requests
Workflow flow.use Binding acme-directory Connector GraphQL External system your directory calls in a step uses requests
A workflow calls an external system inside a step, through a named binding; the connector behind the binding speaks the protocol, such as GraphQL.

A workflow reaches external systems through bindings. A binding is a workflow’s named access to an external system; a connector is the protocol implementation behind it. On this page you write a binding for a GraphQL directory service, use it in a workflow, and replace it with a fixture in a Workflow Test, so the test never calls the real service.

Before you start: a workflow project, such as the starter, and the endpoint and credential of the service. First step: Write a binding. The complete file with its test is the recipe Call a GraphQL service through a binding.

A binding has two parts:

  • defineBinding(name, factory) declares it. The factory is a function without arguments that creates and returns the client: an object with the methods your workflow needs. The factory only creates the client; it does not call the service.
  • flow.use(binding) returns that client inside the workflow. Call the service’s methods inside a step, so the result is recorded and not requested again on replay.

This binding wraps a GraphQL client in a small directory interface. It comes from the GraphQL binding recipe, a file named graphql-binding.workflow.ts:

// 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;
},
};
});

createGraphQLBinding(…) returns a binding factory for a GraphQL client. The AcmeDirectory factory calls it to get the client and returns an object with one method, employee. (): AcmeDirectory => … is a function without arguments whose return type is the AcmeDirectory interface, declared earlier in the same file.

defineBinding('acme-directory', (): AcmeDirectory => {
  1. stable name, fixture key
  2. factory: creates the client

The workflow uses the binding like this:

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;
});
},

flow.use(AcmeDirectory) runs outside the step. That is allowed, because it only returns the client. The call directory.employee(…) runs inside flow.do, so its result is recorded.

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.
  • Give every binding a stable name. A Workflow Test finds the fixture for a binding by this name. Names must not be blank, must not start or end with whitespace, must not contain control characters, and may have at most 120 Unicode characters.
  • Define the binding once, at module level. IdentityFlow calls the factory the first time a workflow uses the binding and reuses the returned client afterwards, also for other instances. It recognizes the binding by the value defineBinding returned, so a binding defined inside the workflow function would create a new client on every run.
  • Keep the factory free of service calls. It can run again at any time, for example after the host restarts.
  • Make service calls tolerate a repeat. A step can run more than once; see Errors and retries.
  • Never return the client from a step. Step results are recorded as event data. Return plain data, never clients, sockets or credentials.
ExportImport from
defineBinding, defineWorkflow, errors@identity-flow/sdk
createGraphQLBinding, gql@identity-flow/binding-graphql, or @identity-flow/sdk/bindings/graphql, which re-exports the same functions

gql is a template tag for a GraphQL document. It builds the query string; it does not check the query against a schema.

createGraphQLBinding(options) accepts these options:

OptionDefault and behavior
urlThe endpoint of the service. If url is missing or undefined, the connector uses the GRAPHQL_ENDPOINT environment variable, then http://localhost:4000/-/graphql.
headersStatic headers, or a function that returns headers. The connector calls the function for every request.
methodPOST; GET is also accepted.
errorPolicynone throws when the response contains GraphQL errors; ignore ignores them; all returns errors and data.
fetchA custom fetch implementation.

In the example, url comes from an environment variable, which is undefined when it is not set:

url: process.env['ACME_GRAPHQL_ENDPOINT'],
  1. then a fallback
  2. undefined when unset

Two consequences for production:

  • Set the URL. Otherwise the connector silently falls back to GRAPHQL_ENDPOINT and finally to localhost. Set the variable in every environment, or pass the URL from your own configuration.
  • Read credentials lazily. Pass headers as a function that reads the credential, as the example does. The credential is then read when a request is sent and never becomes part of the workflow’s recorded data.

The type in client.request<…> describes the expected result for the editor. It does not check the response at run time. Validate responses whose shape you do not control before you use them.

0.3.0 ships only the GraphQL connector. For another protocol, create your own client in a binding factory, for example with fetch. The complete option list is under Outgoing GraphQL. The GraphQL API reference describes IdentityFlow’s own incoming API, which is a different thing.

In a Workflow Test, fixture(binding, value) registers a replacement for the binding before the workflow starts. This replacement is called a fixture. The workflow code stays the same; flow.use(AcmeDirectory) returns the fixture instead of calling the factory. From the recipe’s test, graphql-binding.workflow.case.ts:

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');
});

Register fixtures before start(). A binding without a fixture fails at its first use and never falls back to the real factory. Test workflows explains the test setup.

A connector translates between the workflow and a protocol. Process logic belongs in the workflow, where it is recorded and replayed. In the binding and connector:

  • validate what the other system returns before the workflow relies on it;
  • keep the request metadata you need to diagnose a failure, such as the service’s request ID, but no credentials;
  • make sure the receiving system accepts the same request twice. Errors and retries explains why.