Skip to content

Commit

Permalink
feat: first class abort errors (#1061)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonkuhrt authored Sep 4, 2024
1 parent 1596711 commit 065418a
Show file tree
Hide file tree
Showing 18 changed files with 304 additions and 146 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ coverage
tsconfig.vitest-temp.json
website/.vitepress/dist
website/.vitepress/cache
legacy
1 change: 1 addition & 0 deletions examples/transport-http_abort.output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'This operation was aborted'
32 changes: 32 additions & 0 deletions examples/transport-http_abort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* It is possible to cancel a request using an `AbortController` signal.
*/

import { gql, Graffle } from '../src/entrypoints/main.js'
import { publicGraphQLSchemaEndpoints, show } from './$helpers.js'

const abortController = new AbortController()

const graffle = Graffle.create({
schema: publicGraphQLSchemaEndpoints.SocialStudies,
})

const resultPromise = graffle
.with({ request: { signal: abortController.signal } })
.raw({
document: gql`
{
countries {
name
}
}
`,
})

abortController.abort()

const result = await resultPromise.catch((error: unknown) => (error as Error).message)

show(result)

// todo .with(...) variant
2 changes: 1 addition & 1 deletion examples/transport-http_fetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable */
import { Graffle } from '../src/entrypoints/main.js'
import { show, showJson } from './$helpers.js'
import { showJson } from './$helpers.js'
import { publicGraphQLSchemaEndpoints } from './$helpers.js'

const graffle = Graffle
Expand Down
1 change: 0 additions & 1 deletion src/layers/5_core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,6 @@ export const anyware = Anyware.create<HookSequence, HookMap, ExecutionResult>({
// 1. Generate a map of possible custom scalar paths (tree structure)
// 2. When traversing the result, skip keys that are not in the map
const dataDecoded = Result.decode(getRootIndexOrThrow(input.context, input.rootTypeName), input.result.data)
// console.log(8, Object.keys({ ...input.result, data: dataDecoded }))
switch (input.transport) {
case `memory`: {
return { ...input.result, data: dataDecoded }
Expand Down
4 changes: 3 additions & 1 deletion src/layers/6_client/RootTypeMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ export type RootTypeMethods<$Config extends Config, $Index extends Schema.Index,
// dprint-ignore
type RootMethod<$Config extends Config, $Index extends Schema.Index, $RootTypeName extends Schema.RootTypeName> =
<$SelectionSet extends object>(selectionSet: Exact<$SelectionSet, SelectionSet.Root<$Index, $RootTypeName>>) =>
Promise<ResolveOutputReturnRootType<$Config, $Index, ResultSet.Root<AugmentRootTypeSelectionWithTypename<$Config,$Index,$RootTypeName,$SelectionSet>, $Index, $RootTypeName>>>
Promise<
ResolveOutputReturnRootType<$Config, $Index, ResultSet.Root<AugmentRootTypeSelectionWithTypename<$Config,$Index,$RootTypeName,$SelectionSet>, $Index, $RootTypeName>>
>

// dprint-ignore
type RootTypeFieldMethod<$Context extends RootTypeFieldContext> =
Expand Down
41 changes: 23 additions & 18 deletions src/layers/6_client/Settings/Config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { GraphQLError } from 'graphql'
import type { Simplify } from 'type-fest'
import type { GraphQLExecutionResultError } from '../../../lib/graphql.js'
import type { ConfigManager, StringKeyof, Values } from '../../../lib/prelude.js'
import type { ConfigManager, SimplifyExceptError, StringKeyof, Values } from '../../../lib/prelude.js'
import type { Schema } from '../../1_Schema/__.js'
import type { GlobalRegistry } from '../../2_generator/globalRegistry.js'
import type { SelectionSet } from '../../3_SelectionSet/__.js'
import type { Transport } from '../../5_core/types.js'
import type { ErrorsOther } from '../client.js'
import type { InputStatic } from './Input.js'
import type { RequestInputOptions } from './inputIncrementable/request.js'

Expand Down Expand Up @@ -116,25 +117,29 @@ export type Config = {

// dprint-ignore
export type ResolveOutputReturnRootType<$Config extends Config, $Index extends Schema.Index, $Data> =
| Simplify<IfConfiguredGetOutputErrorReturns<$Config>>
| (
$Config['output']['envelope']['enabled'] extends true
? Envelope<$Config, IfConfiguredStripSchemaErrorsFromDataRootType<$Config, $Index, $Data>>
: Simplify<IfConfiguredStripSchemaErrorsFromDataRootType<$Config, $Index, $Data>>
)
SimplifyExceptError<
| IfConfiguredGetOutputErrorReturns<$Config>
| (
$Config['output']['envelope']['enabled'] extends true
? Envelope<$Config, IfConfiguredStripSchemaErrorsFromDataRootType<$Config, $Index, $Data>>
: Simplify<IfConfiguredStripSchemaErrorsFromDataRootType<$Config, $Index, $Data>>
)
>

// dprint-ignore
export type ResolveOutputReturnRootField<$Config extends Config, $Index extends Schema.Index, $Data, $DataRaw = undefined> =
| IfConfiguredGetOutputErrorReturns<$Config>
| (
$Config['output']['envelope']['enabled'] extends true
// todo: a typed execution result that allows for additional error types.
// currently it is always graphql execution error however envelope configuration can put more errors into that.
? Envelope<$Config, $DataRaw extends undefined
? Simplify<IfConfiguredStripSchemaErrorsFromDataRootField<$Config, $Index, $Data>>
: Simplify<IfConfiguredStripSchemaErrorsFromDataRootType<$Config, $Index, $DataRaw>>>
: Simplify<IfConfiguredStripSchemaErrorsFromDataRootField<$Config, $Index, $Data>>
)
SimplifyExceptError<
| IfConfiguredGetOutputErrorReturns<$Config>
| (
$Config['output']['envelope']['enabled'] extends true
// todo: a typed execution result that allows for additional error types.
// currently it is always graphql execution error however envelope configuration can put more errors into that.
? Envelope<$Config, $DataRaw extends undefined
? Simplify<IfConfiguredStripSchemaErrorsFromDataRootField<$Config, $Index, $Data>>
: Simplify<IfConfiguredStripSchemaErrorsFromDataRootType<$Config, $Index, $DataRaw>>>
: Simplify<IfConfiguredStripSchemaErrorsFromDataRootField<$Config, $Index, $Data>>
)
>

type ObjMap<T = unknown> = {
[key: string]: T
Expand Down Expand Up @@ -193,7 +198,7 @@ type ConfigGetOutputError<$Config extends Config, $ErrorCategory extends ErrorCa
// dprint-ignore
type IfConfiguredGetOutputErrorReturns<$Config extends Config> =
| (ConfigGetOutputError<$Config, 'execution'> extends 'return' ? GraphQLExecutionResultError : never)
| (ConfigGetOutputError<$Config, 'other'> extends 'return' ? Error : never)
| (ConfigGetOutputError<$Config, 'other'> extends 'return' ? ErrorsOther : never)
| (ConfigGetOutputError<$Config, 'schema'> extends 'return' ? Error : never)

// dprint-ignore
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
/* eslint-disable */
import { type ExecutionResult } from 'graphql'
import type { ObjMap } from 'graphql/jsutils/ObjMap.js'
import { describe } from 'node:test'
import type { Simplify } from 'type-fest'
import type { ConditionalSimplify } from 'type-fest/source/conditional-simplify.js'
import { expectTypeOf, test } from 'vitest'
import { Graffle } from '../../../../tests/_/schema/generated/__.js'
import { schema } from '../../../../tests/_/schema/schema.js'
import { type GraphQLExecutionResultError } from '../../../lib/graphql.js'
import { type Envelope, type OutputConfigDefault } from './Config.js'
import type { ErrorsOther } from '../client.js'
import { type Envelope } from './Config.js'

const G = Graffle.create

Expand Down Expand Up @@ -104,15 +102,15 @@ describe('.envelope', () => {
describe('.errors', () => {
test('defaults to execution errors in envelope', () => {
const g = G({ schema, output: { defaults: { errorChannel: 'return' }, envelope: true } })
expectTypeOf(g.query.__typename()).resolves.toEqualTypeOf<ExecutionResult<{ __typename: 'Query' }> | Error>()
expectTypeOf(g.query.__typename()).resolves.toEqualTypeOf<ExecutionResult<{ __typename: 'Query' }> | ErrorsOther>()
})
test('.execution:false restores errors to return', async () => {
const g = G({
schema,
output: { defaults: { errorChannel: 'return' }, envelope: { errors: { execution: false } } },
})
expectTypeOf(g.query.__typename()).resolves.toEqualTypeOf<
Omit<ExecutionResult<{ __typename: 'Query' }>, 'errors'> | Error | GraphQLExecutionResultError
Omit<ExecutionResult<{ __typename: 'Query' }>, 'errors'> | ErrorsOther | GraphQLExecutionResultError
>()
})
test('.other:true raises them to envelope', () => {
Expand Down Expand Up @@ -140,7 +138,7 @@ describe('defaults.errorChannel: "return"', () => {
describe('puts errors into return type', () => {
const g = G({ schema, output: { defaults: { errorChannel: 'return' } } })
test('query.<fieldMethod>', () => {
expectTypeOf(g.query.__typename()).resolves.toEqualTypeOf<'Query' | Error | GraphQLExecutionResultError>()
expectTypeOf(g.query.__typename()).resolves.toEqualTypeOf<'Query' | ErrorsOther | GraphQLExecutionResultError>()
})
})
describe('with .errors', () => {
Expand All @@ -149,7 +147,7 @@ describe('defaults.errorChannel: "return"', () => {
schema,
output: { defaults: { errorChannel: 'return' }, errors: { execution: 'throw' } },
})
expectTypeOf(await g.query.__typename()).toEqualTypeOf<'Query' | Error>()
expectTypeOf(await g.query.__typename()).toEqualTypeOf<'Query' | ErrorsOther>()
})
test('.other: throw', async () => {
const g = G({
Expand Down
32 changes: 30 additions & 2 deletions src/layers/6_client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,42 @@ describe(`transport`, () => {
const request = fetch.mock.calls[0]?.[0]
expect(request?.headers.get(`x-foo`)).toEqual(`bar`)
})

test(`sends well formed request`, async ({ fetch, graffle }) => {
test(`sends spec compliant request`, async ({ fetch, graffle }) => {
fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { greetings: `Hello World` } })))
await graffle.rawString({ document: `query { greetings }` })
const request = fetch.mock.calls[0]?.[0]
expect(request?.headers.get(`content-type`)).toEqual(CONTENT_TYPE_JSON)
expect(request?.headers.get(`accept`)).toEqual(CONTENT_TYPE_GQL)
})
describe(`signal`, () => {
// JSDom and Node result in different errors. JSDom is a plain Error type. Presumably an artifact of JSDom and now in actual browsers.
const abortErrorMessagePattern = /This operation was aborted|AbortError: The operation was aborted/
test(`AbortController at instance level works`, async () => {
const abortController = new AbortController()
const graffle = Graffle.create({
schema: endpoint,
request: { signal: abortController.signal },
})
const resultPromise = graffle.rawString({ document: `query { id }` })
abortController.abort()
const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as {
caughtError: Error
}
expect(caughtError.message).toMatch(abortErrorMessagePattern)
})
test(`AbortController at method level works`, async () => {
const abortController = new AbortController()
const graffle = Graffle.create({
schema: endpoint,
}).with({ request: { signal: abortController.signal } })
const resultPromise = graffle.rawString({ document: `query { id }` })
abortController.abort()
const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as {
caughtError: Error
}
expect(caughtError.message).toMatch(abortErrorMessagePattern)
})
})
})
describe(`memory`, () => {
test(`anyware hooks are typed to memory transport`, () => {
Expand Down
Loading

0 comments on commit 065418a

Please sign in to comment.