Bindings and connectors
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 => {- stable name, fixture key
- 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.
- 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
defineBindingreturned, 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.
| Export | Import 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:
| Option | Default and behavior |
|---|---|
url | The endpoint of the service. If url is missing or undefined, the connector uses the GRAPHQL_ENDPOINT environment variable, then http://localhost:4000/-/graphql. |
headers | Static headers, or a function that returns headers. The connector calls the function for every request. |
method | POST; GET is also accepted. |
errorPolicy | none throws when the response contains GraphQL errors; ignore ignores them; all returns errors and data. |
fetch | A 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'],- then a fallback
- undefined when unset
Two consequences for production:
- Set the URL. Otherwise the connector silently falls back to
GRAPHQL_ENDPOINTand finally tolocalhost. Set the variable in every environment, or pass the URL from your own configuration. - Read credentials lazily. Pass
headersas 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.