From 3c0a60c114e359c25ea745d6ccd0b4e0f207f6e7 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Tue, 1 Oct 2024 22:40:38 -0400 Subject: [PATCH] feat(raw): support typename injection (#1156) --- src/layers/3_ResultSet/decode.ts | 11 +-- src/layers/3_SelectionSetGraphqlMapper/_.ts | 2 +- .../3_SelectionSetGraphqlMapper/helpers.ts | 20 +++++ .../nodes/Argument.ts | 6 +- src/layers/5_core/core.ts | 76 ++++++++++-------- src/layers/5_core/schemaErrors.test.ts | 40 ++++++++-- src/layers/5_core/schemaErrors.ts | 78 +++++++------------ src/layers/6_client/client.ts | 2 + src/layers/7_extensions/Throws/Throws.test.ts | 2 +- tests/_/helpers.ts | 9 +++ 10 files changed, 146 insertions(+), 100 deletions(-) create mode 100644 src/layers/3_SelectionSetGraphqlMapper/helpers.ts diff --git a/src/layers/3_ResultSet/decode.ts b/src/layers/3_ResultSet/decode.ts index 32cc68946..fc131e554 100644 --- a/src/layers/3_ResultSet/decode.ts +++ b/src/layers/3_ResultSet/decode.ts @@ -30,16 +30,17 @@ const getAliasesField = (fieldName: string, ss: Select.SelectionSet.AnySelection const getDataFieldInSelectionSet = ( fieldName: string, selectionSet: Select.SelectionSet.AnySelectionSet, -): { +): null | { fieldName: string selectionSet: Select.SelectionSet.AnyExceptAlias } => { const result = getDataFieldInSelectionSet_(fieldName, selectionSet) if (result) return result - throw new Error( - `Cannot decode field "${fieldName}" in result data. That field was not found in the selection set.`, - ) + return null + // throw new Error( + // `Cannot decode field "${fieldName}" in result data. That field was not found in the selection set.`, + // ) } const getDataFieldInSelectionSet_ = ( @@ -104,10 +105,10 @@ export const decode = <$Data extends ExecutionResult['data']>( return mapValues(data, (value, fieldName) => { const selectionSetField = getDataFieldInSelectionSet(fieldName, selectionSet) + if (!selectionSetField) return value const schemaField = objectType.fields[selectionSetField.fieldName] if (!schemaField) throw new Error(`Field not found in schema: ${String(selectionSetField.fieldName)}`) - const schemaFieldType = readMaybeThunk(schemaField.type) const schemaFieldTypeSansNonNull = Output.unwrapNullable(schemaFieldType) as Output.Named | Output.List const v2 = decodeCustomScalarValue(schemaFieldTypeSansNonNull, selectionSetField.selectionSet, value as any) diff --git a/src/layers/3_SelectionSetGraphqlMapper/_.ts b/src/layers/3_SelectionSetGraphqlMapper/_.ts index f737c9c71..e5941eae2 100644 --- a/src/layers/3_SelectionSetGraphqlMapper/_.ts +++ b/src/layers/3_SelectionSetGraphqlMapper/_.ts @@ -1 +1 @@ -export { toGraphQLDocument } from './nodes/Document.js' +export { toGraphQL } from './helpers.js' diff --git a/src/layers/3_SelectionSetGraphqlMapper/helpers.ts b/src/layers/3_SelectionSetGraphqlMapper/helpers.ts new file mode 100644 index 000000000..98f2fd2d8 --- /dev/null +++ b/src/layers/3_SelectionSetGraphqlMapper/helpers.ts @@ -0,0 +1,20 @@ +import type { Schema } from '../1_Schema/__.js' +import type { Select } from '../2_Select/__.js' +import { toGraphQLDocument } from './nodes/Document.js' + +export const toGraphQL = (input: { + schema: Schema.Index + document: Select.Document.DocumentNormalized +}) => { + return toGraphQLDocument( + { + schema: input.schema, + captures: { + customScalarOutputs: [], + variables: [], + }, + }, + [], + input.document, + ) +} diff --git a/src/layers/3_SelectionSetGraphqlMapper/nodes/Argument.ts b/src/layers/3_SelectionSetGraphqlMapper/nodes/Argument.ts index bb9abc8d0..1692a769f 100644 --- a/src/layers/3_SelectionSetGraphqlMapper/nodes/Argument.ts +++ b/src/layers/3_SelectionSetGraphqlMapper/nodes/Argument.ts @@ -6,7 +6,11 @@ import { toGraphQLValue } from './Value.js' export const toGraphQLArgument: GraphQLNodeMapper< Nodes.ArgumentNode, - [arg: { name: string; type: Schema.Input.Any; value: Select.Arguments.ArgValue }] + [arg: { + name: string + type: Schema.Input.Any + value: Select.Arguments.ArgValue + }] > = ( context, location, diff --git a/src/layers/5_core/core.ts b/src/layers/5_core/core.ts index 65964e0bd..cfdbae2dd 100644 --- a/src/layers/5_core/core.ts +++ b/src/layers/5_core/core.ts @@ -1,4 +1,4 @@ -import { type ExecutionResult, print } from 'graphql' +import { type ExecutionResult, parse, print } from 'graphql' import { Anyware } from '../../lib/anyware/__.js' import { OperationTypeAccessTypeMap, @@ -27,7 +27,7 @@ import { type MethodModeGetReads, } from '../6_client/transportHttp/request.js' import { type HookMap, hookNamesOrderedBySequence, type HookSequence } from './hooks.js' -import { injectTypenameOnResultFields } from './schemaErrors.js' +import { injectTypenameOnRootResultFields } from './schemaErrors.js' export const anyware = Anyware.create({ // If core errors caused by an abort error then raise it as a direct error. @@ -41,47 +41,49 @@ export const anyware = Anyware.create({ hookNamesOrderedBySequence, hooks: { encode: ({ input }) => { - let document: string - let variables: StandardScalarVariables | undefined = undefined + let documentString: string - switch (input.interface) { - case `raw`: { - const documentPrinted = typeof input.document === `string` + const isWillInjectTypename = input.context.config.output.errors.schema && input.context.schemaIndex + + if (isWillInjectTypename) { + const documentObject = input.interface === `raw` + ? typeof input.document === `string` + ? parse(input.document) + : input.document + : SelectionSetGraphqlMapper.toGraphQL({ + schema: input.context.schemaIndex, + document: input.document, + }) + + injectTypenameOnRootResultFields({ + document: documentObject, + operationName: input.operationName, + schema: input.context.schemaIndex!, + }) + + documentString = print(documentObject) + } else { + documentString = input.interface === `raw` + ? typeof input.document === `string` ? input.document : print(input.document) - document = documentPrinted - variables = input.variables - break - } - case `typed`: { - // todo turn inputs into variables - variables = undefined - document = print(SelectionSetGraphqlMapper.toGraphQLDocument( - { - schema: input.context.schemaIndex, - captures: { customScalarOutputs: [], variables: [] }, - }, - [], - input.context.config.output.errors.schema - ? injectTypenameOnResultFields({ - operationName: input.operationName, - schema: input.context.schemaIndex, - document: input.document, - }) - : input.document, - )) - break - } - default: - throw casesExhausted(input) + : print(SelectionSetGraphqlMapper.toGraphQL({ + schema: input.context.schemaIndex, + document: input.document, + })) } + const variables: StandardScalarVariables | undefined = input.interface === `raw` + ? input.variables + // todo turn inputs into variables + : undefined + switch (input.transport) { case `http`: { return { ...input, url: input.schema, - query: document, + query: documentString, variables, } } @@ -89,7 +91,7 @@ export const anyware = Anyware.create({ return { ...input, schema: input.schema, - query: document, + query: documentString, variables, } } @@ -220,6 +222,12 @@ export const anyware = Anyware.create({ throw casesExhausted(input) } }, + // todo + // Given that we manipulate the selection set in encode, and given decode relies on the sent selection set + // it follows that the decode hook depends on the output of the encode hook. that means we need to plumb + // through the hooks that data built during encode. Yet encode doesn't output it currently, but rather prints it. + // Hooks could have a new optional field "schema". When present certain enhanced features would be allowed. + // like custom scalars and result fields. decode: ({ input }) => { switch (input.interface) { // todo this depends on the return mode diff --git a/src/layers/5_core/schemaErrors.test.ts b/src/layers/5_core/schemaErrors.test.ts index b2890ae5c..120858c5a 100644 --- a/src/layers/5_core/schemaErrors.test.ts +++ b/src/layers/5_core/schemaErrors.test.ts @@ -1,8 +1,12 @@ -import { expect, test } from 'vitest' +import { expect } from 'vitest' +import { test } from '../../../tests/_/helpers.js' import { $Index as schema } from '../../../tests/_/schemas/kitchen-sink/graffle/modules/SchemaRuntime.js' import type { Query } from '../../../tests/_/schemas/kitchen-sink/graffle/modules/SelectionSets.js' import { Select } from '../2_Select/__.js' -import { injectTypenameOnResultFields } from './schemaErrors.js' +import { SelectionSetGraphqlMapper } from '../3_SelectionSetGraphqlMapper/__.js' +import { gql } from '../6_helpers/gql.js' +import { Throws } from '../7_extensions/Throws/Throws.js' +import { injectTypenameOnRootResultFields } from './schemaErrors.js' type CasesQuery = [description: string, queryWithoutTypename: Query, queryWithTypename: Query] @@ -17,13 +21,33 @@ test.each([ [`root field in fragment in alias`, { ___: { resultNonNull: [`x`, {}] } }, { ___: { resultNonNull: [`x`, { __typename: true }] }}], [`root field alias `, { resultNonNull: [`x`, {}] }, { resultNonNull: [`x`, { __typename: true }] }], ])(`Query %s`, (_, queryWithoutTypenameInput, queryWithTypenameInput) => { - const documentWithTypename = Select.Document.normalizeOrThrow({ query: { x: queryWithTypenameInput as any } }) - const documentWithoutTypename = Select.Document.normalizeOrThrow({ query: { x: queryWithoutTypenameInput as any } }) - - injectTypenameOnResultFields({ - document:documentWithoutTypename, + const documentWithTypename = SelectionSetGraphqlMapper.toGraphQL({ + schema, + document: Select.Document.normalizeOrThrow({ query: { x: queryWithTypenameInput as any } }) + }) + const documentWithoutTypename = SelectionSetGraphqlMapper.toGraphQL({ + schema, + document: Select.Document.normalizeOrThrow({ query: { x: queryWithoutTypenameInput as any } }) + }) + injectTypenameOnRootResultFields({ + document: documentWithoutTypename, schema, }) - expect(documentWithoutTypename).toMatchObject(documentWithTypename) }) + +test(`type name field injection works for raw string requests`, async ({ kitchenSink }) => { + // todo it would be nicer to move the extension use to the fixture but how would we get the static type for that? + // 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. + const result = await kitchenSink.use(Throws()).throws().rawString({ + document: `query { resultNonNull (case: Object1) { ... on Object1 { id } } }`, + }) + expect(result).toMatchObject({ resultNonNull: { __typename: `Object1`, id: `abc` } }) +}) + +test(`type name field injection works for raw document requests`, async ({ kitchenSink }) => { + const result = await kitchenSink.use(Throws()).throws().raw({ + document: gql`query { resultNonNull (case: Object1) { ... on Object1 { id } } }`, + }) + expect(result).toMatchObject({ resultNonNull: { __typename: `Object1`, id: `abc` } }) +}) diff --git a/src/layers/5_core/schemaErrors.ts b/src/layers/5_core/schemaErrors.ts index bdf7683b6..f606c48a1 100644 --- a/src/layers/5_core/schemaErrors.ts +++ b/src/layers/5_core/schemaErrors.ts @@ -1,73 +1,51 @@ -import type { RootTypeName } from '../../lib/graphql-plus/graphql.js' +import { Nodes, operationTypeNameToRootTypeName, type RootTypeName } from '../../lib/graphql-plus/graphql.js' import type { Schema } from '../1_Schema/__.js' -import { Select } from '../2_Select/__.js' -export const injectTypenameOnResultFields = ( - input: { +export const injectTypenameOnRootResultFields = ( + { document, operationName, schema }: { operationName?: string | undefined schema: Schema.Index - document: Select.Document.DocumentNormalized + document: Nodes.DocumentNode }, -): Select.Document.DocumentNormalized => { - const { document, operationName, schema } = input - const operation = operationName ? document.operations[operationName] : Object.values(document.operations)[0]! +): void => { + const operationDefinition = document.definitions.find(_ => + _.kind === Nodes.Kind.OPERATION_DEFINITION && (operationName ? _.name?.value === operationName : true) + ) as Nodes.OperationDefinitionNode | undefined - if (!operation) { + if (!operationDefinition) { throw new Error(`Operation not found`) } - injectTypenameOnRootResultFields({ - rootTypeName: operation.rootType, + injectTypenameOnRootResultFields_({ + rootTypeName: operationTypeNameToRootTypeName[operationDefinition.operation], schema, - selectionSet: operation.selectionSet, + selectionSet: operationDefinition.selectionSet, }) - - return document } -const injectTypenameOnRootResultFields = ( - input: { +const injectTypenameOnRootResultFields_ = ( + { selectionSet, schema, rootTypeName }: { schema: Schema.Index - selectionSet: Select.SelectionSet.AnySelectionSet rootTypeName: RootTypeName + selectionSet: Nodes.SelectionSetNode }, ): void => { - const { selectionSet, schema, rootTypeName } = input - - for (const [rootFieldName, fieldValue] of Object.entries(selectionSet)) { - const field = Select.parseSelection(rootFieldName, fieldValue) - - switch (field.type) { - case `InlineFragment`: { - // we need to check contents for result root fields - for (const inlineFragmentSelectionSet of field.selectionSets) { - injectTypenameOnRootResultFields({ - rootTypeName, - schema, - selectionSet: inlineFragmentSelectionSet, - }) + for (const selection of selectionSet.selections) { + switch (selection.kind) { + case Nodes.Kind.FIELD: { + if (schema.error.rootResultFields[rootTypeName][selection.name.value]) { + // @ts-expect-error selections is typed as readonly + // @see https://github.com/graphql/graphql-js/discussions/4212 + selection.selectionSet?.selections.push(Nodes.Field({ name: Nodes.Name({ value: `__typename` }) })) } continue } - case `SelectionSet`: { - if (schema.error.rootResultFields[rootTypeName][rootFieldName]) { - field.selectionSet[`__typename`] = true - } - continue - } - case `Alias`: { - if (schema.error.rootResultFields[rootTypeName][rootFieldName]) { - for (const alias of field.aliases) { - // Casting type: This alias is for a field whose type is in rootResultFields - // so it must be a selection set (e.g. not an indicator) - const aliasSelectionSet = alias[1] as Select.SelectionSet.AnySelectionSet - aliasSelectionSet[`__typename`] = true - } - } - continue - } - default: { - continue + case Nodes.Kind.INLINE_FRAGMENT: { + injectTypenameOnRootResultFields_({ + rootTypeName, + schema, + selectionSet: selection.selectionSet, + }) } } } diff --git a/src/layers/6_client/client.ts b/src/layers/6_client/client.ts index 15ab9a87c..6bc9b67d2 100644 --- a/src/layers/6_client/client.ts +++ b/src/layers/6_client/client.ts @@ -59,6 +59,7 @@ export type SelectionSetOrArgs = object export interface RequestContext { config: Config state: State + schemaIndex: Schema.Index | null } export interface InterfaceTypedRequestContext extends RequestContext { @@ -128,6 +129,7 @@ const createWithState = ( // @ts-expect-error fixme config: inputToConfig(state.input), state, + schemaIndex: state.input.schemaIndex ?? null, } /** diff --git a/src/layers/7_extensions/Throws/Throws.test.ts b/src/layers/7_extensions/Throws/Throws.test.ts index 625f6a841..0f2544852 100644 --- a/src/layers/7_extensions/Throws/Throws.test.ts +++ b/src/layers/7_extensions/Throws/Throws.test.ts @@ -40,7 +40,7 @@ describe(`document`, () => { }) test(`.raw() throws if errors array non-empty`, async () => { - await expect(graffle.throws().rawString({ document: `query {}` })).rejects.toMatchInlineSnapshot( + await expect(graffle.throws().rawString({ document: `query { foo }` })).rejects.toMatchInlineSnapshot( `[ContextualAggregateError: One or more errors in the execution result.]`, ) }) diff --git a/tests/_/helpers.ts b/tests/_/helpers.ts index 0df2824a0..57fc2d0ec 100644 --- a/tests/_/helpers.ts +++ b/tests/_/helpers.ts @@ -5,6 +5,9 @@ import type { Config } from '../../src/entrypoints/utilities-for-generated.js' import type { Client } from '../../src/layers/6_client/client.js' import { CONTENT_TYPE_REC } from '../../src/lib/graphqlHTTP.js' import { type SchemaService, serveSchema } from './lib/serveSchema.js' +import { Graffle as KitchenSink } from './schemas/kitchen-sink/graffle/__.js' +import { type Index as KitchenSinkSchemaIndex } from './schemas/kitchen-sink/graffle/modules/SchemaIndex.js' +import { schema as kitchenSinkSchema } from './schemas/kitchen-sink/schema.js' import { schema } from './schemas/pokemon/schema.js' export const createResponse = (body: object) => @@ -13,6 +16,7 @@ export const createResponse = (body: object) => interface Fixtures { fetch: Mock<(request: Request) => Promise> graffle: Client<{ config: Config; schemaIndex: null }> + kitchenSink: Client<{ config: Config; schemaIndex: KitchenSinkSchemaIndex }> pokemonService: SchemaService } @@ -26,6 +30,11 @@ export const test = testBase.extend({ await use(fetchMock) globalThis.fetch = fetch }, + kitchenSink: async ({ fetch: _ }, use) => { + const kitchenSink = KitchenSink.create({ schema: kitchenSinkSchema }) + // @ts-expect-error fixme + await use(kitchenSink) + }, graffle: async ({ fetch: _ }, use) => { const graffle = Graffle.create({ schema: new URL(`https://foo.io/api/graphql`) }) // @ts-expect-error fixme