GraphQLClient
A typed client with one method per operation in a GraphQLGroup. Queries
and mutations return an Effect; subscriptions return a Stream. The
client encodes variables, runs middleware, calls the GraphQLProtocol in
context and decodes results.
Retryable subscription failures restart the middleware chain on the
subscriptionRetry schedule. pages and items provide
cursor pagination. makeTest builds a mock client for tests, and
layerTest provides it as a service layer. See GraphQLProtocol
for transport options.
Constants
The default subscription retry schedule: exponential from 500 milliseconds
with factor 1.5, capped at 5 seconds, and never shorter than the server's
retryAfter.
Signature
declare const defaultSubscriptionRetry: Schedule.Schedule<Duration.Duration, GraphQLClientError>Constructors
Builds a client from a group, requiring GraphQLProtocol and every
middleware service attached to the group or its operations.
Details
Group middleware runs outside operation middleware. Within each level, middleware run in the order attached.
subscriptionRetry decides how a subscription is retried after it fails
with a retryable error: usually a TransportError, but also a
ResponseError whose response carried a valid Retry-After, including an
event with errors on a graphql-sse stream whose response had that header.
It defaults to defaultSubscriptionRetry. Fatal errors are never
retried, whatever the schedule. A retry starts a new subscription, so the
server may send events again that the stream already emitted.
Signature
declare function make<Ops extends Any>(group: GraphQLGroup<Ops>, options?: { readonly subscriptionRetry?: Schedule<unknown, GraphQLClientError, never, never>;}): Effect<GraphQLClient<Ops>, never, GraphQLProtocol | Identifier<Middleware<Ops>>>Example A client as a service
import { Context, Effect, Layer, Schema, Stream } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
// What the generator emits for viewer.graphqlconst Viewer = GraphQL.query("Viewer", { document: "query Viewer{viewer{login}}", result: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) })})const ViewerGraphQLGroup = GraphQLGroup.make(Viewer)
class GitHub extends Context.Service<GitHub>()("app/GitHub", { make: GraphQLClient.make(ViewerGraphQLGroup)}) {}
// A scripted transport; use GraphQLProtocol.layerHttp({ url }) for a real serverconst Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, { execute: () => Effect.succeed({ body: { data: { viewer: { login: "tim" } } } }), subscribe: () => Stream.empty})
const program = GitHub.use((github) => github.Viewer()).pipe( Effect.provide(Layer.effect(GitHub, GitHub.make).pipe(Layer.provide(Scripted))))
await Effect.runPromise(program) // => { viewer: { login: "tim" } }Example Partial results
import { Effect, Layer, Schema, Stream } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
const Repos = GraphQL.query("Repos", { document: "query Repos{a:repository(owner:\"x\",name:\"a\"){name} b:repository(owner:\"x\",name:\"b\"){name}}", result: Schema.Struct({ a: Schema.NullOr(Schema.Struct({ name: Schema.String })), b: Schema.NullOr(Schema.Struct({ name: Schema.String })) })})
const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, { execute: () => Effect.succeed({ body: { data: { a: { name: "a" }, b: null }, errors: [{ message: "Could not resolve to a Repository", path: ["b"] }] } }), subscribe: () => Stream.empty})
const program = Effect.gen(function*() { const client = yield* GraphQLClient.make(GraphQLGroup.make(Repos)) // The default call fails with a ResponseError because `errors` is non-empty const failed = yield* Effect.flip(client.Repos()) // With partial: true the decoded data comes back together with the errors const { data, errors } = yield* client.Repos(undefined, { partial: true }) return [failed.reason._tag, data.a?.name, errors[0].path?.[0]]})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => ["ResponseError", "a", "b"]Models
GraphQLClient
typeA client: one method per operation, keyed by operation name.
Signature
type GraphQLClient<Ops extends GraphQL.Any> = { [Op in Ops]: Method<Op> }The client method type for an operation, by its kind.
Signature
type Method<Op extends GraphQL.Any> = Op["kind"] extends "subscription" ? SubscriptionMethod<Op> : OperationMethod<Op>OperationMethod
interfaceThe client method for a query or mutation.
Details
Called as (variables?, options?). Without partial, or with
partial: false, it returns the decoded result and fails with a
ResponseError on any errors. With partial: true it returns
{ data, errors }. partial must be a literal true or false, not
an arbitrary boolean.
An operation without variables is called as client.Viewer() or, with
options, client.Viewer(undefined, { headers }).
headers and urlParams are sent by the HTTP transport, ignored over
graphql-ws, and visible to middleware either way as
GraphQLRequest.headers and GraphQLRequest.urlParams. Header options
accept Headers.Input and are normalized to Headers.Headers before
middleware runs. context provides
services for this call only, and removes them from the method's
requirements.
Signature
interface OperationMethod<Op extends GraphQL.Any> { <R2 = never>(...args: ArgsWithOptions<Variables<Op>, { readonly context?: Context<R2>; readonly headers?: Input; readonly partial: true; readonly urlParams?: Input; }>): Effect<PartialResult<Result<Op>>, Error<Op>, Exclude<Op["variables"]["EncodingServices"], R2> | Exclude<Op["result"]["DecodingServices"], R2> | Exclude<Requires<Middleware<Op>>, R2>>; <R2 = never>(...args: Args<Variables<Op>, { readonly context?: Context<R2>; readonly headers?: Input; readonly partial?: false; readonly urlParams?: Input; }>): Effect<Result<Op>, Error<Op>, Exclude<Op["variables"]["EncodingServices"], R2> | Exclude<Op["result"]["DecodingServices"], R2> | Exclude<Requires<Middleware<Op>>, R2>>; readonly operation: Op;}PartialResult
interfaceDecoded data and response errors for a { partial: true } call.
The errors array may be empty.
Signature
interface PartialResult<A> { readonly data: A; readonly errors: readonly Array<{ [key: string]: Json; readonly extensions?: { [key: string]: Json; }; readonly locations?: readonly Array<{ readonly column: number; readonly line: number; }>; readonly message: string; readonly path?: readonly Array<string | number>; }>;}SubscriptionMethod
interfaceThe client method for a subscription. It returns a Stream of decoded
events; an event with errors fails the stream with a ResponseError,
unless that error is retryable and the client resubscribes (see
make). headers, urlParams and context work as for
OperationMethod.
Signature
interface SubscriptionMethod<Op extends GraphQL.Any> { <R2 = never>(...args: Args<Variables<Op>, { readonly context?: Context<R2>; readonly headers?: Input; readonly urlParams?: Input; }>): Stream<Result<Op>, Error<Op>, Exclude<Op["variables"]["EncodingServices"], R2> | Exclude<Op["result"]["DecodingServices"], R2> | Exclude<Requires<Middleware<Op>>, R2>>; readonly operation: Op;}Paging
Connection
interfaceWhat the connection getter of pages must return.
Signature
interface Connection { readonly pageInfo: PageInfo;}InvalidAfterVariable
interfaceThe type variables is given when the operation's after variable is
required or does not accept a string. The helper leaves after out on the
first page and sends the previous endCursor afterwards, so it can only
drive an optional variable that takes a string.
Signature
interface InvalidAfterVariable { readonly "effect/graphql": "GraphQLClient.pages requires the `after` variable to be optional and to accept a string";}Like pages, but emits decoded entries from nodes, including
null entries. Use Stream.filter(Predicate.isNotNull) to drop them.
For connections selecting only edges, use pages with
Stream.flatMap.
Signature
declare function items<Op extends Any, Node, R2 = never>(method: OperationMethod<Op>, options: { readonly connection: (result: Result<Op>) => NodesConnection<Node> | null | undefined; readonly options?: { readonly context?: Context<R2>; readonly headers?: Input; readonly urlParams?: Input; }; readonly variables: PagingVariables<Variables<Op>>;}): Stream<Node, Error<Op>, Exclude<Op["variables"]["EncodingServices"], R2> | Exclude<Op["result"]["DecodingServices"], R2> | Exclude<Requires<Middleware<Op>>, R2>>Example Flattening issue numbers across pages
import { Effect, Layer, Schema, Stream } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
const RepoIssues = GraphQL.query("RepoIssues", { document: "query RepoIssues($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){issues(first:2,after:$after){pageInfo{hasNextPage endCursor}nodes{number}}}}", variables: { owner: Schema.String, name: Schema.String, after: Schema.optional(Schema.NullOr(Schema.String)) }, result: Schema.Struct({ repository: Schema.NullOr(Schema.Struct({ issues: Schema.Struct({ pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean, endCursor: Schema.NullOr(Schema.String) }), nodes: Schema.NullOr(Schema.Array(Schema.NullOr(Schema.Struct({ number: Schema.Int })))) }) })) })})
const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, { execute: (request) => { const after = (request.variables as { after?: string }).after const page = after === undefined ? { pageInfo: { hasNextPage: true, endCursor: "c1" }, nodes: [{ number: 1 }, null] } : { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ number: 3 }] } return Effect.succeed({ body: { data: { repository: { issues: page } } } }) }, subscribe: () => Stream.empty})
const program = Effect.gen(function*() { const client = yield* GraphQLClient.make(GraphQLGroup.make(RepoIssues)) const items = GraphQLClient.items(client.RepoIssues, { variables: { owner: "Effect-TS", name: "effect" }, connection: (result) => result.repository?.issues }) return yield* Stream.runCollect(Stream.map(items, (issue) => issue?.number ?? null))})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => [1, null, 3]MissingAfterVariable
interfaceThe type variables is given when the operation has no $after variable.
Nothing is assignable to it, so the mistake is a compile error at the call.
Signature
interface MissingAfterVariable { readonly "effect/graphql": "GraphQLClient.pages requires the operation to declare a nullable `$after: String` variable";}NodesConnection
interfaceWhat the connection getter of items must return: a connection
that also selects nodes.
Signature
interface NodesConnection<Node> extends Connection { readonly nodes: readonly Array<Node> | null;}The pageInfo a paged connection must select.
Signature
interface PageInfo { readonly endCursor: string | null; readonly hasNextPage: boolean;}Pages forward through a cursor connection, emitting one connection per page.
Details
Requires an optional string $after variable (see PagingVariables).
The first request omits after; subsequent requests use the previous
endCursor. Pages are fetched only when pulled, so Stream.take stops
further requests. Middleware runs on each page.
connection extracts the connection from each result. options is
forwarded to every call but cannot include partial: errors fail the stream.
Its headers accept Headers.Input, just as for operation calls.
The stream ends after a page whose hasNextPage is false. A null
connection on the first page gives an empty stream; on a later page it
fails with a PaginationError, as does hasNextPage: true with a null
or unchanged endCursor.
Root fields that come with every page, such as GitHub's rateLimit, are for
middleware to read from the raw ExecutionResult.
Signature
declare function pages<Op extends Any, C extends Connection, R2 = never>(method: OperationMethod<Op>, options: { readonly connection: (result: Result<Op>) => C | null | undefined; readonly options?: { readonly context?: Context<R2>; readonly headers?: Input; readonly urlParams?: Input; }; readonly variables: PagingVariables<Variables<Op>>;}): Stream<C, Error<Op>, Exclude<Op["variables"]["EncodingServices"], R2> | Exclude<Op["result"]["DecodingServices"], R2> | Exclude<Requires<Middleware<Op>>, R2>>Example Paging issues, two per page
import { Effect, Layer, Schema, Stream } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
const RepoIssues = GraphQL.query("RepoIssues", { document: "query RepoIssues($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){issues(first:2,after:$after){pageInfo{hasNextPage endCursor}nodes{number}}}}", variables: { owner: Schema.String, name: Schema.String, after: Schema.optional(Schema.NullOr(Schema.String)) }, result: Schema.Struct({ repository: Schema.NullOr(Schema.Struct({ issues: Schema.Struct({ pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean, endCursor: Schema.NullOr(Schema.String) }), nodes: Schema.NullOr(Schema.Array(Schema.NullOr(Schema.Struct({ number: Schema.Int })))) }) })) })})
// A scripted server with two pagesconst Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, { execute: (request) => { const after = (request.variables as { after?: string }).after const page = after === undefined ? { pageInfo: { hasNextPage: true, endCursor: "c1" }, nodes: [{ number: 1 }, { number: 2 }] } : { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ number: 3 }] } return Effect.succeed({ body: { data: { repository: { issues: page } } } }) }, subscribe: () => Stream.empty})
const program = Effect.gen(function*() { const client = yield* GraphQLClient.make(GraphQLGroup.make(RepoIssues)) const pages = GraphQLClient.pages(client.RepoIssues, { variables: { owner: "Effect-TS", name: "effect" }, connection: (result) => result.repository?.issues }) return yield* Stream.runCollect(Stream.map(pages, (page) => page.pageInfo.endCursor))})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => ["c1", null]PagingVariables
typeThe variables accepted by pages and items: the method's
variables without after, which the helper supplies.
Details
The operation's after variable must be optional and accept a string.
after?: string | null, from a nullable $after: String, and
after?: string, from a $after: String! with a default value, are both
accepted. A required after gives InvalidAfterVariable, as does one
that does not accept a string; no after at all gives
MissingAfterVariable.
Signature
type PagingVariables<Variables> = "after" extends keyof Variables ? {} extends Pick<Variables, "after"> ? string extends Variables["after"] ? Omit<Variables, "after"> : InvalidAfterVariable : InvalidAfterVariable : MissingAfterVariableTesting
Builds a mock layer for a client service using makeTest. Bind the
service and group once to expose a layerTest(handlers) helper on the service.
The group fixes the operation types before the handlers are checked.
See
- makeTest for mock behavior and the middleware-services limitation.
Signature
declare function layerTest<I, Service, Ops extends Any>(service: Key<I, Service>, group: GraphQLGroup<Ops> & GraphQLClient<Ops> extends NoInfer<Service> ? unknown : never): (handlers: TestHandlers<Ops>) => Layer<I>Example Binding a mock layer to a client service
import { Context, Effect, Schema } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup } from "effect/graphql"
const RepoIssues = GraphQL.query("RepoIssues", { document: "query RepoIssues($owner:String!,$name:String!){repository(owner:$owner,name:$name){issues{totalCount}}}", variables: { owner: Schema.String, name: Schema.String }, result: Schema.Struct({ repository: Schema.NullOr(Schema.Struct({ issues: Schema.Struct({ totalCount: Schema.Int }) })) })})const GitHubGraphQLGroup = GraphQLGroup.make(RepoIssues)
class GitHub extends Context.Service<GitHub>()("app/GitHub", { make: GraphQLClient.make(GitHubGraphQLGroup)}) { static readonly layerTest = GraphQLClient.layerTest(GitHub, GitHubGraphQLGroup)}
const program = GitHub.use((github) => github.RepoIssues({ owner: "Effect-TS", name: "effect" }))
await Effect.runPromise(program.pipe(Effect.provide(GitHub.layerTest({ RepoIssues: ({ name }) => Effect.succeed({ repository: { issues: { totalCount: name.length } } })})))) // => { repository: { issues: { totalCount: 6 } } }Builds a client for tests that answers each operation from a handler, without a protocol.
Details
Handlers receive the caller's variables and options unchanged and return
values in their decoded form. Nothing is encoded, decoded or validated, and
no middleware runs. Called with { partial: true }, a method returns
{ data, errors: [] }. Subscriptions are not retried. Every method carries
.operation, so pages and items work on it.
Calling an operation without a handler dies with an UnimplementedError
defect, so catchTag in the code under test cannot swallow a missing stub.
Methods keep the real client's types, including middleware service requirements. Tests must provide those services even though mocks do not use them.
To test partial data together with errors, or the encode and decode path,
provide a scripted GraphQLProtocol layer to make instead.
Signature
declare function makeTest<Ops extends Any>(group: GraphQLGroup<Ops>): (handlers: TestHandlers<Ops>) => GraphQLClient<Ops>Example Replacing a client service in tests
import { Context, Effect, Layer, Schema } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup } from "effect/graphql"
const RepoIssues = GraphQL.query("RepoIssues", { document: "query RepoIssues($owner:String!,$name:String!){repository(owner:$owner,name:$name){issues{totalCount}}}", variables: { owner: Schema.String, name: Schema.String }, result: Schema.Struct({ repository: Schema.NullOr(Schema.Struct({ issues: Schema.Struct({ totalCount: Schema.Int }) })) })})const GitHubGraphQLGroup = GraphQLGroup.make(RepoIssues)
class GitHub extends Context.Service<GitHub>()("app/GitHub", { make: GraphQLClient.make(GitHubGraphQLGroup)}) {}
const GitHubTest = Layer.succeed( GitHub, GraphQLClient.makeTest(GitHubGraphQLGroup)({ RepoIssues: ({ name }) => Effect.succeed({ repository: { issues: { totalCount: name.length } } }) }))
const program = GitHub.use((github) => github.RepoIssues({ owner: "Effect-TS", name: "effect" }))
await Effect.runPromise(program.pipe(Effect.provide(GitHubTest))) // => { repository: { issues: { totalCount: 6 } } }TestCallOptions
interfaceThe options a makeTest handler receives: the headers and
urlParams the caller passed, unchanged. Headers accept Headers.Input;
unlike real transport requests, test call options are not normalized.
Signature
interface TestCallOptions { readonly headers?: Input; readonly urlParams?: Input;}TestHandler
typeA makeTest handler for one operation. Queries and mutations return
an Effect and subscriptions a Stream of the decoded result type.
Signature
type TestHandler<Op extends GraphQL.Any> = Op["kind"] extends "subscription" ? (variables: GraphQL.Variables<Op>, options: TestCallOptions) => Stream.Stream<GraphQL.Result<Op>, GraphQL.Error<Op>> : (variables: GraphQL.Variables<Op>, options: TestCallOptions) => Effect.Effect<GraphQL.Result<Op>, GraphQL.Error<Op>>TestHandlers
typeThe handlers given to makeTest, keyed by operation name. Every handler is optional.
Signature
type TestHandlers<Ops extends GraphQL.Any> = { [Op in Ops]: TestHandler<Op> }