diff --git a/src/client/Config.ts b/src/client/Config.ts index 759055761..9a97a3114 100644 --- a/src/client/Config.ts +++ b/src/client/Config.ts @@ -1,7 +1,22 @@ import type { ExecutionResult } from 'graphql' +import type { GraphQLExecutionResultError } from '../lib/graphql.js' +import type { SetProperty } from '../lib/prelude.js' -// todo: dataAndErrors | dataAndSchemaErrors -export type ReturnModeType = 'graphql' | 'data' +export type ReturnModeType = + | ReturnModeTypeGraphQL + | ReturnModeTypeData + | ReturnModeTypeDataAndSchemaErrors + | ReturnModeTypeDataAllErrors + +export type ReturnModeTypeBase = ReturnModeTypeGraphQL | ReturnModeTypeData | ReturnModeTypeDataAllErrors + +export type ReturnModeTypeGraphQL = 'graphql' + +export type ReturnModeTypeData = 'data' + +export type ReturnModeTypeDataAllErrors = 'dataAndAllErrors' + +export type ReturnModeTypeDataAndSchemaErrors = 'dataAndSchemaErrors' export type OptionsInput = { returnMode: ReturnModeType | undefined @@ -16,9 +31,15 @@ export type Config = { } export type ApplyInputDefaults = { - [Key in keyof OptionsInputDefaults]: undefined extends Input[Key] ? OptionsInputDefaults[Key] : Input[Key] + [Key in keyof OptionsInputDefaults]: undefined extends Input[Key] ? OptionsInputDefaults[Key] + : Exclude } // dprint-ignore -export type ReturnMode<$Config extends Config, $Data> = - $Config['returnMode'] extends 'graphql' ? ExecutionResult<$Data> : $Data +export type ReturnMode<$Config extends Config, $Data, $DataRaw = undefined> = + $Config['returnMode'] extends 'graphql' ? ExecutionResult<$DataRaw extends undefined ? $Data : $DataRaw> : + $Config['returnMode'] extends 'data' ? $Data : + $Data | GraphQLExecutionResultError + +export type OrThrowifyConfig<$Config extends Config> = $Config['returnMode'] extends 'graphql' ? $Config + : SetProperty<$Config, 'returnMode', 'data'> diff --git a/src/client/RootTypeMethods.ts b/src/client/RootTypeMethods.ts index 946f0f49f..1033c2457 100644 --- a/src/client/RootTypeMethods.ts +++ b/src/client/RootTypeMethods.ts @@ -1,25 +1,32 @@ -import type { ExecutionResult } from 'graphql' +import type { OperationName } from '../lib/graphql.js' import type { Exact } from '../lib/prelude.js' import type { TSError } from '../lib/TSError.js' import type { InputFieldsAllNullable, Schema } from '../Schema/__.js' -import type { Config, OptionsInputDefaults, ReturnMode } from './Config.js' +import type { Config, OrThrowifyConfig, ReturnMode } from './Config.js' import type { ResultSet } from './ResultSet/__.js' import type { SelectionSet } from './SelectionSet/__.js' -type OperationName = 'query' | 'mutation' +type RootTypeFieldContext = { + Config: Config + Index: Schema.Index + RootTypeName: Schema.RootTypeName + RootTypeFieldName: string + Field: Schema.SomeField +} // dprint-ignore -export type GetRootTypeMethods<$Config extends OptionsInputDefaults, $Index extends Schema.Index> = { +export type GetRootTypeMethods<$Config extends Config, $Index extends Schema.Index> = { [$OperationName in OperationName as $Index['Root'][Capitalize<$OperationName>] extends null ? never : $OperationName]: RootTypeMethods<$Config, $Index, Capitalize<$OperationName>> } // dprint-ignore -export type RootTypeMethods<$Config extends OptionsInputDefaults, $Index extends Schema.Index, $RootTypeName extends Schema.RootTypeName> = +export type RootTypeMethods<$Config extends Config, $Index extends Schema.Index, $RootTypeName extends Schema.RootTypeName> = $Index['Root'][$RootTypeName] extends Schema.Object$2 ? ( & { $batch: RootMethod<$Config, $Index, $RootTypeName> + $batchOrThrow: RootMethod, $Index, $RootTypeName> } & { [$RootTypeFieldName in keyof $Index['Root'][$RootTypeName]['fields'] & string]: @@ -31,6 +38,16 @@ export type RootTypeMethods<$Config extends OptionsInputDefaults, $Index extends Field: $Index['Root'][$RootTypeName]['fields'][$RootTypeFieldName] }> } + & { + [$RootTypeFieldName in keyof $Index['Root'][$RootTypeName]['fields'] & string as `${$RootTypeFieldName}OrThrow`]: + RootTypeFieldMethod<{ + Config: OrThrowifyConfig<$Config>, + Index: $Index, + RootTypeName: $RootTypeName, + RootTypeFieldName: $RootTypeFieldName + Field: $Index['Root'][$RootTypeName]['fields'][$RootTypeFieldName] + }> + } ) : TSError<'RootTypeMethods', `Your schema does not have the root type "${$RootTypeName}".`> @@ -65,14 +82,4 @@ type ScalarFieldMethod<$Context extends RootTypeFieldContext> = (() => Promise>>) // dprint-ignore type ReturnModeForFieldMethod<$Context extends RootTypeFieldContext, $Data> = - $Context['Config']['returnMode'] extends 'data' - ? $Data - : ExecutionResult<{ [k in $Context['RootTypeFieldName']] : $Data }> - -type RootTypeFieldContext = { - Config: Config - Index: Schema.Index - RootTypeName: Schema.RootTypeName - RootTypeFieldName: string - Field: Schema.SomeField -} + ReturnMode<$Context['Config'], $Data, { [k in $Context['RootTypeFieldName']] : $Data }> diff --git a/src/client/client.document.test.ts b/src/client/client.document.test.ts index 857b7a22f..40abde398 100644 --- a/src/client/client.document.test.ts +++ b/src/client/client.document.test.ts @@ -52,6 +52,11 @@ test(`document with one mutation`, async () => { await expect(run(undefined)).resolves.toEqual({ id: db.id1 }) }) +test(`error`, async () => { + const { run } = client.document({ foo: { query: { error: true } } }) + await expect(run()).rejects.toMatchObject({ errors: [{ message: `Something went wrong.` }] }) +}) + test(`document with one mutation and one query`, async () => { const { run } = client.document({ foo: { diff --git a/src/client/client.input.test-d.ts b/src/client/client.input.test-d.ts new file mode 100644 index 000000000..8adbd0295 --- /dev/null +++ b/src/client/client.input.test-d.ts @@ -0,0 +1,22 @@ +import { test } from 'vitest' +import { $Index } from '../../tests/_/schema/generated/SchemaRuntime.js' +import { schema } from '../../tests/_/schema/schema.js' +import { create } from './client.js' + +test(`works`, () => { + create({ schemaIndex: $Index, schema, name: `QueryOnly`, returnMode: `graphql` }) + create({ schemaIndex: $Index, schema, name: `QueryOnly`, returnMode: `data` }) + create({ schemaIndex: $Index, schema, name: `QueryOnly`, returnMode: `dataAndAllErrors` }) + // @ts-expect-error bad returnMode + create({ schemaIndex: $Index, schema, name: `QueryOnly`, returnMode: `dataAndSchemaErrors` }) + + create({ schemaIndex: $Index, schema, name: `default`, returnMode: `graphql` }) + create({ schemaIndex: $Index, schema, name: `default`, returnMode: `data` }) + create({ schemaIndex: $Index, schema, name: `default`, returnMode: `dataAndAllErrors` }) + create({ schemaIndex: $Index, schema, name: `default`, returnMode: `dataAndSchemaErrors` }) + + create({ schemaIndex: $Index, schema, returnMode: `graphql` }) + create({ schemaIndex: $Index, schema, returnMode: `data` }) + create({ schemaIndex: $Index, schema, returnMode: `dataAndAllErrors` }) + create({ schemaIndex: $Index, schema, returnMode: `dataAndSchemaErrors` }) +}) diff --git a/src/client/client.returnMode.test-d.ts b/src/client/client.returnMode.test-d.ts index 37aa8b0cb..8c97e1340 100644 --- a/src/client/client.returnMode.test-d.ts +++ b/src/client/client.returnMode.test-d.ts @@ -5,23 +5,24 @@ import { describe } from 'node:test' import { expectTypeOf, test } from 'vitest' import { $Index as schemaIndex } from '../../tests/_/schema/generated/SchemaRuntime.js' import { schema } from '../../tests/_/schema/schema.js' +import { GraphQLExecutionResultError } from '../lib/graphql.js' import { create } from './client.js' // dprint-ignore -describe('default', () => { +describe('default is data', () => { const client = create({ schema, schemaIndex }) test(`document`, async () => { expectTypeOf(client.document({ main: { query: { id: true } } }).run()).resolves.toEqualTypeOf<{ id: string | null }>() }) - test(`raw`, async () => { - expectTypeOf(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqualTypeOf() - }) test('query field method', async () => { await expectTypeOf(client.query.__typename()).resolves.toEqualTypeOf<'Query'>() }) test('query $batch', async () => { await expectTypeOf(client.query.$batch({ __typename: true, id: true })).resolves.toEqualTypeOf<{ __typename: 'Query', id: string|null }>() }) + test(`raw`, async () => { + expectTypeOf(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqualTypeOf() + }) }) // dprint-ignore @@ -30,14 +31,42 @@ describe('data', () => { test(`document`, async () => { expectTypeOf(client.document({ main: { query: { id: true } } }).run()).resolves.toEqualTypeOf<{ id: string | null }>() }) + test('query field method', async () => { + await expectTypeOf(client.query.__typename()).resolves.toEqualTypeOf<'Query'>() + }) + test('query $batch', async () => { + await expectTypeOf(client.query.$batch({ __typename: true, id: true })).resolves.toEqualTypeOf<{ __typename: 'Query', id: string|null }>() + }) + test('result',async () => { + const x = await client.query.result({$: { case: 'Object1' }, onObject1:{id:true},onErrorOne:{infoId:true},onErrorTwo:{infoInt:true}}) + await expectTypeOf(client.query.result({$: { case: 'Object1' }, onObject1:{id:true},onErrorOne:{infoId:true},onErrorTwo:{infoInt:true}})).resolves.toEqualTypeOf() + }) test(`raw`, async () => { expectTypeOf(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqualTypeOf() }) +}) + +// dprint-ignore +describe('dataAndAllErrors', () => { + const client = create({ schema, schemaIndex, returnMode: 'dataAndAllErrors' }) + test(`document`, async () => { + expectTypeOf(client.document({ main: { query: { id: true } } }).run()).resolves.toEqualTypeOf<{ id: string | null } | GraphQLExecutionResultError>() + }) + test(`document runOrThrow`, async () => { + expectTypeOf(client.document({ main: { query: { id: true } } }).runOrThrow()).resolves.toEqualTypeOf<{ id: string | null }>() + }) test('query field method', async () => { - await expectTypeOf(client.query.__typename()).resolves.toEqualTypeOf<'Query'>() + await expectTypeOf(client.query.__typename()).resolves.toEqualTypeOf<'Query' | GraphQLExecutionResultError>() }) test('query $batch', async () => { - await expectTypeOf(client.query.$batch({ __typename: true, id: true })).resolves.toEqualTypeOf<{ __typename: 'Query', id: string|null }>() + await expectTypeOf(client.query.$batch({ __typename: true, id: true })).resolves.toEqualTypeOf<{ __typename: 'Query', id: string|null } | GraphQLExecutionResultError>() + }) + test('result',async () => { + const x = await client.query.result({$: { case: 'Object1' }, onObject1:{id:true},onErrorOne:{infoId:true},onErrorTwo:{infoInt:true}}) + await expectTypeOf(client.query.result({$: { case: 'Object1' }, onObject1:{id:true},onErrorOne:{infoId:true},onErrorTwo:{infoInt:true}})).resolves.toEqualTypeOf() + }) + test(`raw`, async () => { + expectTypeOf(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqualTypeOf() }) }) @@ -47,13 +76,22 @@ describe('graphql', () => { test(`document`, async () => { expectTypeOf(client.document({ main: { query: { id: true } } }).run()).resolves.toEqualTypeOf>>() }) - test(`raw`, async () => { - expectTypeOf(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqualTypeOf() + test(`document runOrThrow`, async () => { + expectTypeOf(client.document({ main: { query: { id: true } } }).runOrThrow()).resolves.toEqualTypeOf>>() }) test('query field method', async () => { await expectTypeOf(client.query.__typename()).resolves.toEqualTypeOf>() }) + test('query field methodOrThrow', async () => { + await expectTypeOf(client.query.__typenameOrThrow()).resolves.toEqualTypeOf>() + }) test('query $batch', async () => { await expectTypeOf(client.query.$batch({ __typename: true, id: true })).resolves.toEqualTypeOf>() }) + test('query $batchOrThrow', async () => { + await expectTypeOf(client.query.$batchOrThrow({ __typename: true, id: true })).resolves.toEqualTypeOf>() + }) + test(`raw`, async () => { + expectTypeOf(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqualTypeOf() + }) }) diff --git a/src/client/client.returnMode.test.ts b/src/client/client.returnMode.test.ts index f7a45b6e2..34bc0d146 100644 --- a/src/client/client.returnMode.test.ts +++ b/src/client/client.returnMode.test.ts @@ -1,26 +1,43 @@ /* eslint-disable */ +import { GraphQLError } from 'graphql' import { describe, expect, test } from 'vitest' import { db } from '../../tests/_/db.js' import { $Index as schemaIndex } from '../../tests/_/schema/generated/SchemaRuntime.js' import { schema } from '../../tests/_/schema/schema.js' +import { Errors } from '../lib/errors/__.js' import { __typename } from '../Schema/_.js' import { create } from './client.js' // dprint-ignore -describe('default', () => { +describe('default (data)', () => { const client = create({ schema, schemaIndex }) test(`document`, async () => { await expect(client.document({ main: { query: { id: true } } }).run()).resolves.toEqual({ id: db.id }) }) + test(`document runOrThrow`, async () => { + await expect(client.document({ main: { query: { id: true } } }).runOrThrow()).resolves.toEqual({ id: db.id }) + }) + test(`document runOrThrow error`, async () => { + await expect(client.document({ main: { query: { error: true } } }).runOrThrow()).rejects.toEqual(db.error) + }) test('raw', async () => { await expect(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqual({ data: { id: db.id } }) }) test('query field method', async () => { await expect(client.query.__typename()).resolves.toEqual('Query') }) + test('query field method error', async () => { + await expect(client.query.error()).rejects.toMatchObject(db.error) + }) + test('query field method error orThrow', async () => { + await expect(client.query.errorOrThrow()).rejects.toMatchObject(db.error) + }) test('query $batch', async () => { await expect(client.query.$batch({ __typename: true, id: true })).resolves.toEqual({ __typename: 'Query', id: db.id }) }) + test('query $batchOrThrow error', async () => { + await expect(client.query.$batchOrThrow({ error: true })).rejects.toMatchObject(db.error) + }) test('mutation field method', async () => { await expect(client.mutation.__typename()).resolves.toEqual('Mutation') }) @@ -30,20 +47,35 @@ describe('default', () => { }) // dprint-ignore -describe('data', () => { - const client = create({ schema, schemaIndex, returnMode: 'data' }) +describe('dataAndAllErrors', () => { + const client = create({ schema, schemaIndex, returnMode: 'dataAndAllErrors' }) test(`document`, async () => { await expect(client.document({ main: { query: { id: true } } }).run()).resolves.toEqual({ id: db.id }) }) + test(`document runOrThrow`, async () => { + await expect(client.document({ main: { query: { id: true } } }).runOrThrow()).resolves.toEqual({ id: db.id }) + }) + test(`document runOrThrow error`, async () => { + await expect(client.document({ main: { query: { error: true } } }).runOrThrow()).rejects.toEqual(db.error) + }) test('raw', async () => { await expect(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqual({ data: { id: db.id } }) }) test('query field method', async () => { await expect(client.query.__typename()).resolves.toEqual('Query') }) + test('query field method error', async () => { + await expect(client.query.error()).resolves.toMatchObject(db.error) + }) + test('query field method error orThrow', async () => { + await expect(client.query.errorOrThrow()).rejects.toMatchObject(db.error) + }) test('query $batch', async () => { await expect(client.query.$batch({ __typename: true, id: true })).resolves.toEqual({ __typename: 'Query', id: db.id }) }) + test('query $batchOrThrow error', async () => { + await expect(client.query.$batchOrThrow({ error: true })).rejects.toMatchObject(db.error) + }) test('mutation field method', async () => { await expect(client.mutation.__typename()).resolves.toEqual('Mutation') }) @@ -58,15 +90,30 @@ describe('graphql', () => { test(`document`, async () => { await expect(client.document({ main: { query: { id: true } } }).run()).resolves.toEqual({ data: { id: db.id } }) // dprint-ignore }) + test(`document runOrThrow`, async () => { + await expect(client.document({ main: { query: { id: true } } }).runOrThrow()).resolves.toEqual({data:{ id: db.id }}) + }) + test(`document runOrThrow error`, async () => { + await expect(client.document({ main: { query: { error: true } } }).runOrThrow()).rejects.toEqual(db.error) + }) test('raw', async () => { await expect(client.raw('query main {\nid\n}', {}, 'main')).resolves.toEqual({ data: { id: db.id } }) }) test('query field method', async () => { await expect(client.query.__typename()).resolves.toEqual({ data: { __typename: 'Query' } }) }) + test('query field method error', async () => { + await expect(client.query.error()).resolves.toMatchObject({ errors:db.error['errors'] }) + }) + test('query field method error orThrow', async () => { + await expect(client.query.errorOrThrow()).rejects.toMatchObject(db.error) + }) test('query $batch', async () => { await expect(client.query.$batch({ __typename: true, id: true })).resolves.toEqual({ data: { __typename: 'Query', id: db.id } }) }) + test('query $batchOrThrow error', async () => { + await expect(client.query.$batchOrThrow({ error: true })).rejects.toMatchObject(db.error) + }) test('mutation field method', async () => { await expect(client.mutation.__typename()).resolves.toEqual({ data: { __typename: 'Mutation' } }) }) diff --git a/src/client/client.rootTypeMethods.test-d.ts b/src/client/client.rootTypeMethods.test-d.ts index 5de71d9ee..417088f93 100644 --- a/src/client/client.rootTypeMethods.test-d.ts +++ b/src/client/client.rootTypeMethods.test-d.ts @@ -1,6 +1,7 @@ /* eslint-disable */ import { expectTypeOf, test } from 'vitest' import * as Schema from '../../tests/_/schema/schema.js' +import { GraphQLExecutionResultError } from '../lib/graphql.js' import { create } from './client.js' const client = create({ schema: Schema.schema, schemaIndex: Schema.$Index }) diff --git a/src/client/client.rootTypeMethods.test.ts b/src/client/client.rootTypeMethods.test.ts index 5436dd3ec..5c8945fdb 100644 --- a/src/client/client.rootTypeMethods.test.ts +++ b/src/client/client.rootTypeMethods.test.ts @@ -5,6 +5,7 @@ import { create } from './client.js' const client = create({ schema: Schema.schema, schemaIndex: Schema.$Index }) +// dprint-ignore describe(`query`, () => { test(`scalar`, async () => { await expect(client.query.id()).resolves.toEqual(db.id1) @@ -30,11 +31,33 @@ describe(`query`, () => { await expect(client.query.interface({ id: true })).resolves.toEqual({ id: db.id }) }) test(`interface instance found`, async () => { - await expect(client.query.interface({ onObject1ImplementingInterface: { int: true } })).resolves.toEqual({ - int: db.int, - }) + await expect(client.query.interface({ onObject1ImplementingInterface: { int: true } })).resolves.toEqual({ int: db.int }) }) test(`interface instance not found`, async () => { await expect(client.query.interface({ onObject2ImplementingInterface: { boolean: true } })).resolves.toEqual({}) }) + describe(`orThrow`, () => { + test(`without error`, async () => { + await expect(client.query.objectWithArgsOrThrow({ $: { id: `x` }, id: true })).resolves.toEqual({ id: `x` }) + }) + test(`with error`, async () => { + await expect(client.query.errorOrThrow()).rejects.toMatchObject(db.error) + }) + }) + describe(`$batch`, () => { + test(`success`, async () => { + await expect(client.query.$batch({ id: true })).resolves.toMatchObject({ id:db.id }) + }) + test(`error`, async () => { + await expect(client.query.$batch({ error: true })).rejects.toMatchObject(db.error) + }) + describe(`orThrow`, () => { + test(`success`, async () => { + await expect(client.query.$batchOrThrow({ id: true })).resolves.toMatchObject({ id:db.id }) + }) + test(`error`, async () => { + await expect(client.query.$batchOrThrow({ error: true })).rejects.toMatchObject(db.error) + }) + }) + }) }) diff --git a/src/client/client.test.ts b/src/client/client.test.ts deleted file mode 100644 index aec3922a7..000000000 --- a/src/client/client.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, test } from 'vitest' -import { $Index as schemaIndex } from '../../tests/_/schema/generated/SchemaRuntime.js' -import { setupMockServer } from '../../tests/raw/__helpers.js' -import { create } from './client.js' - -const ctx = setupMockServer() -const data = { unionFooBar: { int: 1 } } - -const client = () => create({ schema: ctx.url, schemaIndex }) - -test.todo(`query`, async () => { - const mockRes = ctx.res({ body: { data } }).spec.body! - expect(await client().query.$batch({ unionFooBar: { onBar: { int: true } } })).toEqual(mockRes.data) -}) diff --git a/src/client/client.ts b/src/client/client.ts index 75b1eeaa1..c02f2e85a 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -2,11 +2,13 @@ import type { ExecutionResult } from 'graphql' import { type DocumentNode, execute, graphql, type GraphQLSchema } from 'graphql' import type { ExcludeUndefined } from 'type-fest/source/required-deep.js' import request from '../entrypoints/main.js' +import type { GlobalRegistry } from '../globalRegistry.js' +import { Errors } from '../lib/errors/__.js' import type { RootTypeName, Variables } from '../lib/graphql.js' import type { Object$2 } from '../Schema/__.js' import { Schema } from '../Schema/__.js' import { readMaybeThunk } from '../Schema/core/helpers.js' -import type { ApplyInputDefaults, OptionsInputDefaults, ReturnModeType } from './Config.js' +import type { ApplyInputDefaults, Config, ReturnModeTypeBase, ReturnModeTypeDataAndSchemaErrors } from './Config.js' import * as CustomScalars from './customScalars.js' import type { DocumentFn } from './document.js' import { toDocumentExpression } from './document.js' @@ -15,7 +17,7 @@ import { SelectionSet } from './SelectionSet/__.js' import type { DocumentObject, GraphQLObjectSelection } from './SelectionSet/toGraphQLDocumentString.js' // dprint-ignore -export type Client<$Index extends Schema.Index, $Config extends OptionsInputDefaults> = +export type Client<$Index extends Schema.Index, $Config extends Config> = & { // todo test raw raw: (document: string | DocumentNode, variables?:Variables, operationName?:string) => Promise @@ -28,37 +30,41 @@ interface HookInputDocumentEncode { documentObject: GraphQLObjectSelection } -interface Input { - /** - * @defaultValue 'default' - */ - name?: keyof NamedSchemas - schema: URL | string | GraphQLSchema - headers?: HeadersInit - /** - * Used internally for several functions. - * - * When custom scalars are being used, this runtime schema is used to - * encode/decode them before/after your application sends/receives them. - * - * When using root type field methods, this runtime schema is used to assist how arguments on scalars versus objects - * are constructed into the sent GraphQL document. - */ - schemaIndex: Schema.Index - returnMode?: ReturnModeType - hooks?: { - documentEncode: ( - input: HookInputDocumentEncode, - fn: (input: HookInputDocumentEncode) => GraphQLObjectSelection, - ) => GraphQLObjectSelection +type InputForSchema<$Name extends GlobalRegistry.SchemaNames> = $Name extends any ? { + /** + * @defaultValue 'default' + */ + name?: $Name + schema: URL | string | GraphQLSchema + headers?: HeadersInit + /** + * Used internally for several functions. + * + * When custom scalars are being used, this runtime schema is used to + * encode/decode them before/after your application sends/receives them. + * + * When using root type field methods, this runtime schema is used to assist how arguments on scalars versus objects + * are constructed into the sent GraphQL document. + */ + schemaIndex: Schema.Index + returnMode?: + | ReturnModeTypeBase + | (GlobalRegistry.HasSchemaErrors<$Name> extends true ? ReturnModeTypeDataAndSchemaErrors : never) + hooks?: { + documentEncode: ( + input: HookInputDocumentEncode, + fn: (input: HookInputDocumentEncode) => GraphQLObjectSelection, + ) => GraphQLObjectSelection + } } -} + : never + +type Input = InputForSchema export const create = <$Input extends Input>( input: $Input, ): Client< - // @ts-expect-error fixme - (undefined extends $Input['name'] ? NamedSchemas['default']['index'] : NamedSchemas[$Input['name']]['index']), + GlobalRegistry.GetSchemaIndexOptionally<$Input['name']>, ApplyInputDefaults<{ returnMode: $Input['returnMode'] }> > => { const parentInput = input @@ -127,8 +133,7 @@ export const create = <$Input extends Input>( const documentString = SelectionSet.selectionSet(documentObjectEncoded) // todo variables const result = await executeDocumentExpression({ document: documentString }) - if (result.errors && (result.errors.length > 0)) throw new AggregateError(result.errors) - // todo check for errors + // if (result.errors && (result.errors.length > 0)) throw new AggregateError(result.errors) const dataDecoded = CustomScalars.decode(rootIndex, result.data) return { ...result, data: dataDecoded } } @@ -159,16 +164,24 @@ export const create = <$Input extends Input>( } as GraphQLObjectSelection const result = await rootObjectExecutors[rootTypeName](documentObject) const resultHandled = handleReturn(result) + if (resultHandled instanceof Error) return resultHandled // @ts-expect-error make this type safe? - return returnMode === `data` ? resultHandled[key] : resultHandled + return returnMode === `data` || returnMode === `dataAndAllErrors` ? resultHandled[key] : resultHandled } } const handleReturn = (result: ExecutionResult) => { switch (returnMode) { + case `dataAndAllErrors`: case `data`: { if (result.errors && result.errors.length > 0) { - throw new AggregateError(result.errors) + const error = new Errors.ContextualAggregateError( + `One or more errors in the execution result.`, + {}, + result.errors, + ) + if (returnMode === `data`) throw error + return error } return result.data } @@ -188,15 +201,46 @@ export const create = <$Input extends Input>( new Proxy({}, { get: (_, key) => { if (typeof key === `symbol`) throw new Error(`Symbols not supported.`) - if (key === `$batch`) { + + // todo We need to document that in order for this to 100% work none of the user's root type fields can end with "OrThrow". + const isOrThrow = key.endsWith(`OrThrow`) + + if (key.startsWith(`$batch`)) { return async (selectionSetOrIndicator: GraphQLObjectSelection) => { - const result = await rootObjectExecutors[rootTypeName]({ + const resultRaw = await rootObjectExecutors[rootTypeName]({ [rootTypeNameToOperationName[rootTypeName]]: selectionSetOrIndicator, }) - return handleReturn(result) + const result = handleReturn(resultRaw) + if (isOrThrow && result instanceof Error) throw result + // todo consolidate + // @ts-expect-error fixme + if (isOrThrow && returnMode === `graphql` && result.errors && result.errors.length > 0) { + throw new Errors.ContextualAggregateError( + `One or more errors in the execution result.`, + {}, + // @ts-expect-error fixme + result.errors, + ) + } + return result } } else { - return executeRootTypeFieldSelection(rootTypeName, key) + const fieldName = isOrThrow ? key.slice(0, -7) : key + return async (argsOrSelectionSet?: object) => { + const result = await executeRootTypeFieldSelection(rootTypeName, fieldName)(argsOrSelectionSet) // eslint-disable-line + if (isOrThrow && result instanceof Error) throw result + // todo consolidate + // eslint-disable-next-line + if (isOrThrow && returnMode === `graphql` && result.errors.length > 0) { + throw new Errors.ContextualAggregateError( + `One or more errors in the execution result.`, + {}, + // eslint-disable-next-line + result.errors, + ) + } + return result + } } }, }) @@ -207,16 +251,27 @@ export const create = <$Input extends Input>( return await executeDocumentExpression({ document, variables, operationName }) }, document: (documentObject: DocumentObject) => { + const run = async (operationName: string) => { + // todo this does not support custom scalars + const documentExpression = toDocumentExpression(documentObject) + const result = await executeDocumentExpression({ + document: documentExpression, + operationName, + // todo variables + }) + return handleReturn(result) + } return { - run: async (operationName: string) => { - // todo this does not support custom scalars - const documentExpression = toDocumentExpression(documentObject) - const result = await executeDocumentExpression({ - document: documentExpression, - operationName, - // todo variables - }) - return handleReturn(result) + run, + runOrThrow: async (operationName: string) => { + const result = await run(operationName) + if (result instanceof Error) throw result + // @ts-expect-error fixme + if (returnMode === `graphql` && result.errors && result.errors.length > 0) { + // @ts-expect-error fixme + throw new Errors.ContextualAggregateError(`One or more errors in the execution result.`, {}, result.errors) + } + return result }, } }, diff --git a/src/client/document.ts b/src/client/document.ts index 7a2ed9eb5..d73df0c4d 100644 --- a/src/client/document.ts +++ b/src/client/document.ts @@ -2,7 +2,7 @@ import type { MergeExclusive, NonEmptyObject } from 'type-fest' import type { IsMultipleKeys } from '../lib/prelude.js' import type { TSError } from '../lib/TSError.js' import type { Schema } from '../Schema/__.js' -import type { Config, ReturnMode } from './Config.js' +import type { Config, OrThrowifyConfig, ReturnMode } from './Config.js' import type { ResultSet } from './ResultSet/__.js' import { SelectionSet } from './SelectionSet/__.js' import type { DocumentObject } from './SelectionSet/toGraphQLDocumentString.js' @@ -17,6 +17,12 @@ export type DocumentFn<$Config extends Config, $Index extends Schema.Index> = >(...params: $Params) => Promise< ReturnMode<$Config, ResultSet.Root, $Index, 'Query'>> > + runOrThrow: < + $Name extends keyof $Document & string, + $Params extends (IsMultipleKeys<$Document> extends true ? [name: $Name] : ([] | [name: $Name | undefined])), + >(...params: $Params) => Promise< + ReturnMode, ResultSet.Root, $Index, 'Query'>> + > } export const toDocumentExpression = ( diff --git a/src/generator/__snapshots__/files.test.ts.snap b/src/generator/__snapshots__/files.test.ts.snap index f1e466b7d..204520e6c 100644 --- a/src/generator/__snapshots__/files.test.ts.snap +++ b/src/generator/__snapshots__/files.test.ts.snap @@ -249,6 +249,12 @@ export namespace Root { dateNonNull: $.Field<$Scalar.Date, null> dateObject1: $.Field<$.Output.Nullable, null> dateUnion: $.Field<$.Output.Nullable, null> + error: $.Field< + $.Output.Nullable<$Scalar.String>, + $.Args<{ + case: $.Input.Nullable<$Scalar.String> + }> + > id: $.Field<$.Output.Nullable<$Scalar.ID>, null> idNonNull: $.Field<$Scalar.ID, null> interface: $.Field<$.Output.Nullable, null> @@ -651,6 +657,7 @@ export const Query = $.Object$(\`Query\`, { dateNonNull: $.field($Scalar.Date), dateObject1: $.field($.Output.Nullable(() => DateObject1)), dateUnion: $.field($.Output.Nullable(() => DateUnion)), + error: $.field($.Output.Nullable($Scalar.String), $.Args({ case: $.Input.Nullable($Scalar.String) })), id: $.field($.Output.Nullable($Scalar.ID)), idNonNull: $.field($Scalar.ID), interface: $.field($.Output.Nullable(() => Interface)), diff --git a/src/generator/code/global.ts b/src/generator/code/global.ts index 184d425c6..03d47e866 100644 --- a/src/generator/code/global.ts +++ b/src/generator/code/global.ts @@ -35,6 +35,9 @@ export const generateGlobal = (config: Config) => { }).join(`\n`) } } + featureOptions: { + schemaErrors: ${config.options.errorTypeNamePattern ? `true` : `false`} + } } } } diff --git a/src/global.ts b/src/global.ts deleted file mode 100644 index 2157491b1..000000000 --- a/src/global.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare global { - // todo some kind of distinct prefix - interface NamedSchemas {} -} diff --git a/src/globalRegistry.ts b/src/globalRegistry.ts new file mode 100644 index 000000000..914e5ad58 --- /dev/null +++ b/src/globalRegistry.ts @@ -0,0 +1,41 @@ +import type { TSError } from './lib/TSError.js' +import type { Schema } from './Schema/__.js' + +declare global { + // todo some kind of distinct prefix + interface NamedSchemas {} +} + +export type GlobalRegistry = Record + featureOptions: { + schemaErrors: boolean + } +}> + +export namespace GlobalRegistry { + export type Schemas = NamedSchemas + + export type DefaultSchemaName = 'default' + + export type SchemaNames = keyof NamedSchemas extends never + ? TSError<'SchemaNames', 'No schemas have been registered. Did you run graphql-request generate?'> + : keyof NamedSchemas + + // todo use conditional types? + // eslint-disable-next-line + // @ts-ignore populated after generation + export type HasSchemaErrors<$Name extends SchemaNames> = NamedSchemas[$Name]['featureOptions']['schemaErrors'] + + // todo use conditional types? + // eslint-disable-next-line + // @ts-ignore populated after generation + export type GetSchemaIndexOptionally<$Name extends SchemaNames | undefined> = $Name extends SchemaNames + // eslint-disable-next-line + // @ts-ignore populated after generation + ? NamedSchemas[$Name]['index'] + // eslint-disable-next-line + // @ts-ignore populated after generation + : NamedSchemas['default']['index'] +} diff --git a/src/lib/errors/ContextualAggregateError.ts b/src/lib/errors/ContextualAggregateError.ts new file mode 100644 index 000000000..e450727a1 --- /dev/null +++ b/src/lib/errors/ContextualAggregateError.ts @@ -0,0 +1,26 @@ +import { ContextualError } from './ContextualError.js' +import type { Cause } from './types.js' + +/** + * Aggregation Error enhanced with a context object and types members. + * + * The library also exports a serializer you can use. + */ +export class ContextualAggregateError< + $Errors extends Error | ContextualError = ContextualError< + string, + object, + Cause | undefined + >, + $Name extends string = `ContextualAggregateError`, + $Context extends object = object, +> extends ContextualError<$Name, $Context> { + override name: $Name = `ContextualAggregateError` as $Name + constructor( + message: string, + context: $Context, + public readonly errors: readonly $Errors[], + ) { + super(message, context, undefined) + } +} diff --git a/src/lib/errors/ContextualError.ts b/src/lib/errors/ContextualError.ts new file mode 100644 index 000000000..48896f7a9 --- /dev/null +++ b/src/lib/errors/ContextualError.ts @@ -0,0 +1,23 @@ +import type { Cause, Context } from './types.js' + +/** + * Error enhanced with a context object. + * + * The library also exports a serializer you can use. + */ +export class ContextualError< + $Name extends string = string, + $Context extends Context = object, + $Cause extends Cause | undefined = undefined, +> extends Error { + override name: $Name = `ContextualError` as $Name + constructor( + message: string, + public readonly context: $Context = {} as $Context, + public override readonly cause: $Cause = undefined as $Cause, + ) { + super(message, cause) + } +} + +export type SomeContextualError = ContextualError diff --git a/src/lib/errors/ErrorInternal.ts b/src/lib/errors/ErrorInternal.ts new file mode 100644 index 000000000..aa881649a --- /dev/null +++ b/src/lib/errors/ErrorInternal.ts @@ -0,0 +1,17 @@ +import { ContextualError } from './ContextualError.js' +import type { Cause, Context } from './types.js' + +export class ErrorInternal< + $Name extends string = 'ErrorInternal', + $Context extends Context = Context, + $Cause extends Cause | undefined = undefined, +> extends ContextualError<$Name, $Context, $Cause> { + override name: $Name = `ErrorInternal` as $Name + constructor( + message: string = `Something went wrong.`, + context: $Context = {} as $Context, + cause: $Cause = undefined as $Cause, + ) { + super(message, context, cause) + } +} diff --git a/src/lib/errors/_.ts b/src/lib/errors/_.ts new file mode 100644 index 000000000..fbf44e545 --- /dev/null +++ b/src/lib/errors/_.ts @@ -0,0 +1,4 @@ +export * from './ContextualAggregateError.js' +export * from './ContextualError.js' +export * from './ErrorInternal.js' +export * from './types.js' diff --git a/src/lib/errors/__.ts b/src/lib/errors/__.ts new file mode 100644 index 000000000..acbdaf5ea --- /dev/null +++ b/src/lib/errors/__.ts @@ -0,0 +1 @@ +export * as Errors from './_.js' diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts new file mode 100644 index 000000000..3249d86c4 --- /dev/null +++ b/src/lib/errors/types.ts @@ -0,0 +1,4 @@ +import type { ContextualError } from './ContextualError.js' + +export type Cause = Error | ContextualError +export type Context = object diff --git a/src/lib/graphql.ts b/src/lib/graphql.ts index 2712e9f03..d3fd2dd33 100644 --- a/src/lib/graphql.ts +++ b/src/lib/graphql.ts @@ -1,4 +1,4 @@ -import type { GraphQLEnumValue, GraphQLField, GraphQLInputField, GraphQLSchema } from 'graphql' +import type { GraphQLEnumValue, GraphQLError, GraphQLField, GraphQLInputField, GraphQLSchema } from 'graphql' import { GraphQLEnumType, GraphQLInputObjectType, @@ -11,6 +11,7 @@ import { isListType, isNonNullType, } from 'graphql' +import type { Errors } from './errors/__.js' export type TypeMapByKind = & { @@ -230,3 +231,7 @@ export const hasSubscription = (typeMapByKind: TypeMapByKind) => typeMapByKind.GraphQLRootType.find((_) => _.name === `Subscription`) export type Variables = Record // todo or any custom scalars too + +export type GraphQLExecutionResultError = Errors.ContextualAggregateError + +export type OperationName = 'query' | 'mutation' diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index 82fbac380..4edb5648f 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -185,6 +185,7 @@ import type { ConditionalSimplifyDeep } from 'type-fest/source/conditional-simpl export type SimplifyDeep = ConditionalSimplifyDeep | Date, object> import fs from 'node:fs/promises' +import { $ } from 'vitest/dist/reporters-LqC_WI4d.js' export const fileExists = async (path: string) => { return Boolean( @@ -229,3 +230,7 @@ export const mapValues = < }), ) as Record> } + +export type SetProperty<$Obj extends object, $Prop extends keyof $Obj, $Type extends $Obj[$Prop]> = + & Omit<$Obj, $Prop> + & { [_ in $Prop]: $Type } diff --git a/tests/_/db.ts b/tests/_/db.ts index 225f071ee..753509628 100644 --- a/tests/_/db.ts +++ b/tests/_/db.ts @@ -1,5 +1,13 @@ +import { GraphQLError } from 'graphql' +import { Errors } from '../../src/lib/errors/__.js' + const date0 = new Date(0) +// const error = { errors: [{ message: `Something went wrong.` }] } +const error = new Errors.ContextualAggregateError(`One or more errors in the execution result.`, {}, [ + new GraphQLError(`Something went wrong.`), +]) + const id = `abc` const int = 123 @@ -54,4 +62,5 @@ export const db = { DateInterface1: { date1: date0, }, + error, } as const diff --git a/tests/_/schema/generated/Global.ts b/tests/_/schema/generated/Global.ts index d41a5ff19..22c3c4790 100644 --- a/tests/_/schema/generated/Global.ts +++ b/tests/_/schema/generated/Global.ts @@ -9,6 +9,9 @@ declare global { customScalars: { Date: CustomScalar.Date } + featureOptions: { + schemaErrors: true + } } } } diff --git a/tests/_/schema/generated/SchemaBuildtime.ts b/tests/_/schema/generated/SchemaBuildtime.ts index f4bda1e7e..602b21210 100644 --- a/tests/_/schema/generated/SchemaBuildtime.ts +++ b/tests/_/schema/generated/SchemaBuildtime.ts @@ -71,6 +71,12 @@ export namespace Root { dateNonNull: $.Field<$Scalar.Date, null> dateObject1: $.Field<$.Output.Nullable, null> dateUnion: $.Field<$.Output.Nullable, null> + error: $.Field< + $.Output.Nullable<$Scalar.String>, + $.Args<{ + case: $.Input.Nullable<$Scalar.String> + }> + > id: $.Field<$.Output.Nullable<$Scalar.ID>, null> idNonNull: $.Field<$Scalar.ID, null> interface: $.Field<$.Output.Nullable, null> diff --git a/tests/_/schema/generated/SchemaRuntime.ts b/tests/_/schema/generated/SchemaRuntime.ts index 55f64efb2..f1fcc01ca 100644 --- a/tests/_/schema/generated/SchemaRuntime.ts +++ b/tests/_/schema/generated/SchemaRuntime.ts @@ -143,6 +143,7 @@ export const Query = $.Object$(`Query`, { dateNonNull: $.field($Scalar.Date), dateObject1: $.field($.Output.Nullable(() => DateObject1)), dateUnion: $.field($.Output.Nullable(() => DateUnion)), + error: $.field($.Output.Nullable($Scalar.String), $.Args({ case: $.Input.Nullable($Scalar.String) })), id: $.field($.Output.Nullable($Scalar.ID)), idNonNull: $.field($Scalar.ID), interface: $.field($.Output.Nullable(() => Interface)), diff --git a/tests/_/schema/schema.graphql b/tests/_/schema/schema.graphql index a6856912b..31b125d7d 100644 --- a/tests/_/schema/schema.graphql +++ b/tests/_/schema/schema.graphql @@ -129,6 +129,7 @@ type Query { dateNonNull: Date! dateObject1: DateObject1 dateUnion: DateUnion + error(case: String): String id: ID idNonNull: ID! interface: Interface diff --git a/tests/_/schema/schema.ts b/tests/_/schema/schema.ts index 52bc65469..ef831eb61 100644 --- a/tests/_/schema/schema.ts +++ b/tests/_/schema/schema.ts @@ -211,6 +211,13 @@ const ObjectUnion = builder.simpleObject(`ObjectUnion`, { builder.queryType({ fields: t => ({ + // error + error: t.string({ + args: { case: t.arg.string({ required: false }) }, + resolve: () => { + throw new Error(`Something went wrong.`) + }, + }), // Custom Scalar date: t.field({ type: `Date`, resolve: () => db.date0 }), dateNonNull: t.field({ nullable: false, type: `Date`, resolve: () => db.date0 }), diff --git a/tests/_/schemaMutationOnly/generated/Global.ts b/tests/_/schemaMutationOnly/generated/Global.ts index 057e83abb..2413e61cf 100644 --- a/tests/_/schemaMutationOnly/generated/Global.ts +++ b/tests/_/schemaMutationOnly/generated/Global.ts @@ -5,6 +5,9 @@ declare global { MutationOnly: { index: Index customScalars: {} + featureOptions: { + schemaErrors: false + } } } } diff --git a/tests/_/schemaQueryOnly/generated/Global.ts b/tests/_/schemaQueryOnly/generated/Global.ts index 3c68df87b..ab6b13dae 100644 --- a/tests/_/schemaQueryOnly/generated/Global.ts +++ b/tests/_/schemaQueryOnly/generated/Global.ts @@ -5,6 +5,9 @@ declare global { QueryOnly: { index: Index customScalars: {} + featureOptions: { + schemaErrors: false + } } } } diff --git a/tsconfig.json b/tsconfig.json index aa07d718c..ebf73a07b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,6 @@ ], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2023"], - // Use ESM "module": "NodeNext", "moduleResolution": "nodenext",