Skip to content

Commit 7f7ffd3

Browse files
authored
improve(extension/schema-errors): move req runtime to ext (#1184)
1 parent 44cab1e commit 7f7ffd3

File tree

8 files changed

+139
-111
lines changed

8 files changed

+139
-111
lines changed

src/layers/5_request/core.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const graffleMappedToRequest = (
4141
: Object.values(operationsVariables)[0]
4242

4343
const operation_ = getOperationDefinition({ query: document, operationName })
44-
if (!operation_) throw new Error(`Impossible.`)
44+
if (!operation_) throw new Error(`Unknown operation named "${String(operationName)}".`)
4545

4646
return {
4747
rootType: operationTypeToRootType[operation_.operation],

src/layers/6_client/gql/gql.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export const gqlProperties = defineTerminus((state) => {
7878
extensions: state.extensions.filter(_ => _.onRequest !== undefined).map(_ => _.onRequest!) as any,
7979
})
8080

81-
return handleOutput(state, analyzedRequest.rootType, result)
81+
return handleOutput(state, result)
8282
},
8383
} as any
8484
},

src/layers/6_client/handleOutput.ts

Lines changed: 1 addition & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import type { ExecutionResult, GraphQLError } from 'graphql'
2-
import { SchemaDrivenDataMap } from '../../layers/7_extensions/CustomScalars/schemaDrivenDataMap/types.js'
32
import { Errors } from '../../lib/errors/__.js'
4-
import type { Grafaid } from '../../lib/grafaid/__.js'
53
import type { GraphQLExecutionResultError } from '../../lib/grafaid/graphql.js'
6-
import { isRecordLikeObject, isString, type Values } from '../../lib/prelude.js'
4+
import { type Values } from '../../lib/prelude.js'
75
import type { SchemaIndex } from '../4_generator/generators/SchemaIndex.js'
86
import type { TransportHttp } from '../5_request/types.js'
97
import type { State } from './fluent.js'
@@ -44,7 +42,6 @@ export type GraffleExecutionResultVar<$Config extends Config = Config> =
4442

4543
export const handleOutput = (
4644
state: State,
47-
rootTypeName: Grafaid.Schema.RootTypeName,
4845
result: GraffleExecutionResultVar,
4946
) => {
5047
if (isContextConfigTraditionalGraphQLOutput(state.config)) {
@@ -69,10 +66,6 @@ export const handleOutput = (
6966
const isReturnExecution = readConfigErrorCategoryOutputChannel(config, `execution`) === `return`
7067
&& (!c.envelope.enabled || !c.envelope.errors.execution)
7168

72-
const isThrowSchema = readConfigErrorCategoryOutputChannel(config, `schema`) === `throw`
73-
74-
const isReturnSchema = readConfigErrorCategoryOutputChannel(config, `schema`) === `return`
75-
7669
if (result instanceof Error) {
7770
if (isThrowOther) throw result
7871
if (isReturnOther) return result
@@ -91,50 +84,6 @@ export const handleOutput = (
9184
return isEnvelope ? { ...result, errors: [...result.errors ?? [], error] } : error
9285
}
9386

94-
if (state.input.schemaMap) {
95-
if (c.errors.schema !== false) {
96-
if (!isRecordLikeObject(result.data)) throw new Error(`Expected data to be an object.`)
97-
const schemaErrors = Object.entries(result.data).map(([rootFieldName, rootFieldValue]) => {
98-
// todo this check would be nice but it doesn't account for aliases right now. To achieve this we would
99-
// need to have the selection set available to use and then do a costly analysis for all fields that were aliases.
100-
// So costly that we would probably instead want to create an index of them on the initial encoding step and
101-
// then make available down stream.
102-
// const sddmNodeField = state.input.schemaMap?.roots[rootTypeName]?.f[rootFieldName]
103-
// if (!sddmNodeField) return null
104-
// if (!isPlainObject(rootFieldValue)) return new Error(`Expected result field to be an object.`)
105-
if (!isRecordLikeObject(rootFieldValue)) return null
106-
107-
// If __typename is not selected we assume that this is not a result field.
108-
// The extension makes sure that the __typename would have been selected if it were a result field.
109-
const __typename = rootFieldValue[`__typename`]
110-
if (!isString(__typename)) return null
111-
112-
const sddmNode = state.input.schemaMap?.types[__typename]
113-
const isErrorObject = SchemaDrivenDataMap.isOutputObject(sddmNode) && Boolean(sddmNode.e)
114-
if (!isErrorObject) return null
115-
// todo extract message
116-
// todo allow mapping error instances to schema errors
117-
return new Error(`Failure on field ${rootFieldName}: ${__typename}`)
118-
}).filter((_): _ is Error => _ !== null)
119-
120-
const error = (schemaErrors.length === 1)
121-
? schemaErrors[0]!
122-
: schemaErrors.length > 0
123-
? new Errors.ContextualAggregateError(
124-
`Two or more schema errors in the execution result.`,
125-
{},
126-
schemaErrors,
127-
)
128-
: null
129-
if (error) {
130-
if (isThrowSchema) throw error
131-
if (isReturnSchema) {
132-
return isEnvelope ? { ...result, errors: [...result.errors ?? [], error] } : error
133-
}
134-
}
135-
}
136-
}
137-
13887
if (isEnvelope) {
13988
return result
14089
}

src/layers/6_client/requestMethods/document.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ describe(`document with two queries`, () => {
4141
const { run } = withTwo
4242
// @ts-expect-error
4343
const error = await run().catch((e: unknown) => e) as Errors.ContextualAggregateError
44-
expect(error.message).toEqual(`Must provide operation name if query contains multiple operations.`)
44+
expect(error.errors[0]?.message).toEqual(`Must provide operation name if query contains multiple operations.`)
4545
})
4646
test(`error if wrong operation name is provided`, async () => {
4747
const { run } = withTwo
4848
// @ts-expect-error
4949
const error = await run(`boo`).catch((e: unknown) => e) as Errors.ContextualAggregateError
50-
expect(error.message).toEqual(`Unknown operation named "boo".`)
50+
expect(error.cause?.message).toEqual(`Unknown operation named "boo".`)
5151
})
5252
test(`error if no operations provided`, () => {
5353
expect(() => {

src/layers/6_client/requestMethods/requestMethods.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { Fluent } from '../../../lib/fluent/__.js'
33
import type { Grafaid } from '../../../lib/grafaid/__.js'
44
import { isSymbol } from '../../../lib/prelude.js'
55
import { Select } from '../../2_Select/__.js'
6-
import { getOperationOrThrow } from '../../2_Select/document.js'
76
import type { GlobalRegistry } from '../../4_generator/globalRegistry.js'
87
import { RequestCore } from '../../5_request/__.js'
98
import { type ClientContext, defineTerminus, type State } from '../fluent.js'
@@ -112,8 +111,6 @@ export const executeDocument = async (
112111
const url = state.config.transport.type === `http` ? state.config.transport.url : undefined
113112
const schema = state.config.transport.type === `http` ? undefined : state.config.transport.schema
114113

115-
const { rootType } = getOperationOrThrow(document, operationName)
116-
117114
const initialInput = {
118115
state,
119116
interfaceType,
@@ -133,5 +130,5 @@ export const executeDocument = async (
133130
extensions: state.extensions.filter(_ => _.onRequest !== undefined).map(_ => _.onRequest!) as any,
134131
})
135132

136-
return handleOutput(state, rootType, result)
133+
return handleOutput(state, result)
137134
}

src/layers/7_extensions/SchemaErrors/SchemaErrors.test.ts

Lines changed: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,41 +6,64 @@ import { Graffle } from '../../../../tests/_/schemas/kitchen-sink/graffle/__.js'
66
import { schemaDrivenDataMap } from '../../../../tests/_/schemas/kitchen-sink/graffle/modules/SchemaDrivenDataMap.js'
77
import type { Query } from '../../../../tests/_/schemas/kitchen-sink/graffle/modules/SelectionSets.js'
88
import { schema } from '../../../../tests/_/schemas/kitchen-sink/schema.js'
9+
import type { Errors } from '../../../lib/errors/__.js'
910
import { Select } from '../../2_Select/__.js'
1011
import { SelectionSetGraphqlMapper } from '../../3_SelectGraphQLMapper/__.js'
1112
import { graffleMappedToRequest } from '../../5_request/core.js'
1213
import { injectTypenameOnRootResultFields } from '../../5_request/schemaErrors.js'
13-
import { Throws } from '../Throws/Throws.js'
1414

15-
const graffle = Graffle.create({ schema }).use(Throws())
15+
const graffle = Graffle
16+
.create({ schema })
17+
.with({
18+
output: {
19+
defaults: { errorChannel: `return` },
20+
errors: { schema: `default` },
21+
},
22+
})
1623

1724
describe(`document`, () => {
1825
describe(`query result field`, () => {
1926
test(`with __typename`, async () => {
20-
const result = graffle.throws().document({
21-
query: { x: { resultNonNull: { $: { $case: `ErrorOne` }, __typename: true } } },
22-
})
23-
.run()
24-
await expect(result).rejects.toMatchInlineSnapshot(`[Error: Failure on field resultNonNull: ErrorOne]`)
27+
const result = (await graffle
28+
.document({ query: { x: { resultNonNull: { $: { $case: `ErrorOne` }, __typename: true } } } })
29+
.run()) as Errors.ContextualAggregateError
30+
expect(result.errors[0]).toMatchInlineSnapshot(`[Error: Failure on field resultNonNull: ErrorOne]`)
31+
})
32+
test(`__typename is dynamically added at runtime if missing`, async () => {
33+
const result = (await graffle
34+
.document({ query: { x: { resultNonNull: { $: { $case: `ErrorOne` } } } } })
35+
.run()) as Errors.ContextualAggregateError
36+
expect(result.errors[0]).toMatchInlineSnapshot(`[Error: Failure on field resultNonNull: ErrorOne]`)
2537
})
26-
test(`without __typename still works, __typename is dynamically added at runtime`, async () => {
27-
const result = graffle.throws().document({ query: { x: { resultNonNull: { $: { $case: `ErrorOne` } } } } }).run()
28-
await expect(result).rejects.toMatchInlineSnapshot(
29-
`[Error: Failure on field resultNonNull: ErrorOne]`,
38+
test(`multiple errors`, async () => {
39+
const result = (await graffle
40+
.document({
41+
query: {
42+
x: {
43+
result: { $: { $case: `ErrorOne` } },
44+
resultNonNull: { $: { $case: `ErrorOne` } },
45+
},
46+
},
47+
})
48+
.run()) as Errors.ContextualAggregateError
49+
50+
expect(result.errors[0]).toMatchInlineSnapshot(
51+
`[ContextualAggregateError: Two or more schema errors in the execution result.]`,
3052
)
3153
})
32-
test(`multiple via alias`, async () => {
33-
const result = graffle.throws().document({
34-
query: {
35-
x: {
36-
resultNonNull: [
37-
[`resultNonNull`, { $: { $case: `ErrorOne` } }],
38-
[`x`, { $: { $case: `ErrorOne` } }],
39-
],
54+
test(`multiple errors via alias`, async () => {
55+
const result = (await graffle
56+
.document({
57+
query: {
58+
x: {
59+
resultNonNull: [[`resultNonNull`, { $: { $case: `ErrorOne` } }], [`x`, { $: { $case: `ErrorOne` } }]],
60+
},
4061
},
41-
},
42-
}).run()
43-
await expect(result).rejects.toMatchInlineSnapshot(
62+
})
63+
// todo rename to "send" to match gql
64+
.run()) as Errors.ContextualAggregateError
65+
66+
expect(result.errors[0]).toMatchInlineSnapshot(
4467
`[ContextualAggregateError: Two or more schema errors in the execution result.]`,
4568
)
4669
})
@@ -49,12 +72,12 @@ describe(`document`, () => {
4972

5073
describe(`query non-result field`, () => {
5174
test(`without error`, async () => {
52-
await expect(graffle.throws().query.objectWithArgs({ $: { id: `x` }, id: true })).resolves.toEqual({
75+
await expect(graffle.query.objectWithArgs({ $: { id: `x` }, id: true })).resolves.toEqual({
5376
id: `x`,
5477
})
5578
})
5679
test(`with error`, async () => {
57-
await expect(graffle.throws().query.error()).rejects.toMatchObject(db.errorAggregate)
80+
expect(await graffle.query.error()).toMatchObject(db.errorAggregate)
5881
})
5982
})
6083

@@ -84,17 +107,20 @@ test.each<CasesQuery>([
84107
expect(mappedResultWithTypename.document).toMatchObject(mappedResultWithoutTypename.document)
85108
})
86109

87-
// dprint-ignore
88110
test(`gql string request`, async ({ kitchenSink }) => {
89111
// todo it would be nicer to move the extension use to the fixture but how would we get the static type for that?
90112
// This makes me think of a feature we need to have. Make it easy to get static types of the client in its various configured states.
91-
const result = await kitchenSink.use(Throws()).throws().gql`query { resultNonNull (case: Object1) { ... on Object1 { id } } }`.send()
113+
const result = await kitchenSink
114+
.with({ output: { errors: { schema: `default` } } })
115+
.gql`query { resultNonNull (case: Object1) { ... on Object1 { id } } }`
116+
.send()
92117
expect(result).toMatchObject({ resultNonNull: { __typename: `Object1`, id: `abc` } })
93118
})
94119

95120
test(`gql document request`, async ({ kitchenSink }) => {
96-
const result = await kitchenSink.use(Throws()).throws().gql(
97-
parse(`query { resultNonNull (case: Object1) { ... on Object1 { id } } }`),
98-
).send()
121+
const result = await kitchenSink
122+
.with({ output: { errors: { schema: `default` } } })
123+
.gql(parse(`query { resultNonNull (case: Object1) { ... on Object1 { id } } }`))
124+
.send()
99125
expect(result).toMatchObject({ resultNonNull: { __typename: `Object1`, id: `abc` } })
100126
})
Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import { Errors } from '../../../lib/errors/__.js'
12
import { normalizeRequestToNode } from '../../../lib/grafaid/request.js'
3+
import { isString } from '../../../lib/prelude.js'
4+
import { isRecordLikeObject } from '../../../lib/prelude.js'
25
import { injectTypenameOnRootResultFields } from '../../5_request/schemaErrors.js'
36
import { createExtension } from '../../6_client/extension/extension.js'
7+
import { SchemaDrivenDataMap } from '../CustomScalars/schemaDrivenDataMap/types.js'
48

59
// todo?: augment config to include how schema errors should be handled in the output
610
// todo: manipulate results: 1) schema errors should be thrown or returned (outside envelope) depending on config.
@@ -10,8 +14,11 @@ export const SchemaErrors = () => {
1014
return createExtension({
1115
name: `SchemaErrors`,
1216
onRequest: async ({ pack }) => {
13-
const sddm = pack.input.state.config.schemaMap
14-
if (!sddm) return pack()
17+
const state = pack.input.state
18+
const sddm = state.config.schemaMap
19+
const config = state.config
20+
21+
if (!sddm || !config.output.errors.schema) return pack()
1522

1623
const request = normalizeRequestToNode(pack.input.request)
1724

@@ -20,7 +27,49 @@ export const SchemaErrors = () => {
2027

2128
injectTypenameOnRootResultFields({ sddm, request })
2229

23-
return pack()
30+
const { exchange } = await pack()
31+
const { unpack } = await exchange()
32+
const { decode } = await unpack()
33+
const result = await decode()
34+
35+
if (result instanceof Error || !result.data) return result
36+
37+
const schemaErrors: Error[] = []
38+
for (const [rootFieldName, rootFieldValue] of Object.entries(result.data)) {
39+
// todo this check would be nice but it doesn't account for aliases right now. To achieve this we would
40+
// need to have the selection set available to use and then do a costly analysis for all fields that were aliases.
41+
// So costly that we would probably instead want to create an index of them on the initial encoding step and
42+
// then make available down stream.
43+
// const sddmNodeField = sddm.roots[rootTypeName]?.f[rootFieldName]
44+
// if (!sddmNodeField) return null
45+
// if (!isPlainObject(rootFieldValue)) return new Error(`Expected result field to be an object.`)
46+
if (!isRecordLikeObject(rootFieldValue)) continue
47+
48+
// If __typename is not selected we assume that this is not a result field.
49+
// The extension makes sure that the __typename would have been selected if it were a result field.
50+
const __typename = rootFieldValue[`__typename`]
51+
if (!isString(__typename)) continue
52+
53+
const sddmNode = sddm.types[__typename]
54+
const isErrorObject = SchemaDrivenDataMap.isOutputObject(sddmNode) && Boolean(sddmNode.e)
55+
if (!isErrorObject) continue
56+
57+
// todo extract message
58+
// todo allow mapping error instances to schema errors
59+
schemaErrors.push(new Error(`Failure on field ${rootFieldName}: ${__typename}`))
60+
}
61+
62+
const error = (schemaErrors.length === 1)
63+
? schemaErrors[0]!
64+
: schemaErrors.length > 0
65+
? new Errors.ContextualAggregateError(`Two or more schema errors in the execution result.`, {}, schemaErrors)
66+
: null
67+
68+
if (error) {
69+
result.errors = [...result.errors ?? [], error as any]
70+
}
71+
72+
return result
2473
},
2574
})
2675
}

0 commit comments

Comments
 (0)