diff --git a/src/extension/extension.ts b/src/extension/extension.ts index f0786e3e1..3fc6b2779 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -4,7 +4,7 @@ import type { Context } from '../layers/6_client/context.js' import type { GraffleExecutionResultEnvelope } from '../layers/6_client/handleOutput.js' import type { Anyware } from '../lib/anyware/__.js' import type { Builder } from '../lib/chain/__.js' -import type { AssertExtends } from '../lib/prelude.js' +import type { AssertExtends, ToParameters } from '../lib/prelude.js' import type { TypeFunction } from '../lib/type-function/__.js' import type { Fn } from '../lib/type-function/TypeFunction.js' import type { RequestPipeline } from '../requestPipeline/__.js' @@ -44,6 +44,7 @@ export interface EmptyTypeHooks { export interface Extension< $Name extends string = string, + $Config extends object | undefined = object | undefined, $BuilderExtension extends BuilderExtension | undefined = BuilderExtension | undefined, $TypeHooks extends TypeHooks = TypeHooks, > extends Fn { @@ -51,6 +52,7 @@ export interface Extension< * The name of the extension */ name: $Name + config: $Config /** * Anyware executed on every request. */ @@ -135,13 +137,42 @@ export const createExtension = < $Name extends string, $BuilderExtension extends BuilderExtension | undefined = undefined, $TypeHooks extends TypeHooks = TypeHooks, + $ConfigInput extends object = object, + $Config extends object = object, + $Custom extends object = object, >( - extension: { + extensionInput: { name: $Name - builder?: $BuilderExtension - onRequest?: Anyware.Extension2 - typeHooks?: () => $TypeHooks + normalizeConfig?: (input?: $ConfigInput) => $Config + custom?: $Custom + create: (params: { config: $Config }) => { + builder?: $BuilderExtension + onRequest?: Anyware.Extension2 + typeHooks?: () => $TypeHooks + } }, -): Extension<$Name, $BuilderExtension, $TypeHooks> => { - return extension as any +): ExtensionConstructor< + $ConfigInput, + $Config, + $Name, + $BuilderExtension, + $TypeHooks, + $Custom +> => { + const extensionConstructor = (input: any) => { + const config = (extensionInput.normalizeConfig?.(input) ?? {}) as any + return extensionInput.create({ config }) as any + } + return extensionConstructor as any } + +export type ExtensionConstructor< + $ConfigInput extends undefined | object, + $Config extends object, + $Name extends string, + $BuilderExtension extends BuilderExtension | undefined = undefined, + $TypeHooks extends TypeHooks = TypeHooks, + $Custom extends object = object, +> = + & ((...args: ToParameters<$ConfigInput>) => Extension<$Name, $Config, $BuilderExtension, $TypeHooks>) + & $Custom diff --git a/src/extensions/Introspection/Introspection.ts b/src/extensions/Introspection/Introspection.ts index f8cdd3aaf..40f338153 100644 --- a/src/extensions/Introspection/Introspection.ts +++ b/src/extensions/Introspection/Introspection.ts @@ -4,9 +4,7 @@ import type { SimplifyNullable } from '../../entrypoints/main.js' import type { Context } from '../../layers/6_client/context.js' import type { HandleOutput } from '../../layers/6_client/handleOutput.js' import type { Builder } from '../../lib/chain/__.js' -import { createConfig, type Input } from './config.js' - -const knownPotentiallyUnsupportedFeatures = [`inputValueDeprecation`, `oneOf`] as const +import { type ConfigInput, createConfig } from './config.js' /** * This extension adds a `.introspect` method to the client that will return the introspected schema. @@ -24,45 +22,49 @@ const knownPotentiallyUnsupportedFeatures = [`inputValueDeprecation`, `oneOf`] a * const data = await graffle.introspect() * ``` */ -export const Introspection = (input?: Input) => { - const config = createConfig(input) +export const Introspection = createExtension({ + name: `Introspection`, + normalizeConfig: (input?: ConfigInput) => { + const config = createConfig(input) + return config + }, + create: ({ config }) => { + return { + builder: createBuilderExtension(({ path, property, client }) => { + if (!(path.length === 0 && property === `introspect`)) return + const clientCatching = client.with({ output: { envelope: false, errors: { execution: `return` } } }) - return createExtension({ - name: `Introspection`, - builder: createBuilderExtension(({ path, property, client }) => { - if (!(path.length === 0 && property === `introspect`)) return - const clientCatching = client.with({ output: { envelope: false, errors: { execution: `return` } } }) + return async () => { + let introspectionQueryDocument = getIntrospectionQuery(config.options) + const result = await clientCatching.gql(introspectionQueryDocument).send() + const featuresDropped: string[] = [] + const enabledKnownPotentiallyUnsupportedFeatures = knownPotentiallyUnsupportedFeatures.filter(_ => + config.options[_] !== false + ) - return async () => { - let introspectionQueryDocument = getIntrospectionQuery(config.options) - const result = await clientCatching.gql(introspectionQueryDocument).send() - const featuresDropped: string[] = [] - const enabledKnownPotentiallyUnsupportedFeatures = knownPotentiallyUnsupportedFeatures.filter(_ => - config.options[_] !== false - ) - - // Try to find a working introspection query. - if (result instanceof Error) { - for (const feature of enabledKnownPotentiallyUnsupportedFeatures) { - featuresDropped.push(feature) - introspectionQueryDocument = getIntrospectionQuery({ - ...config.options, - [feature]: false, - }) - const result = await clientCatching.gql(introspectionQueryDocument).send() - if (!(result instanceof Error)) break + // Try to find a working introspection query. + if (result instanceof Error) { + for (const feature of enabledKnownPotentiallyUnsupportedFeatures) { + featuresDropped.push(feature) + introspectionQueryDocument = getIntrospectionQuery({ + ...config.options, + [feature]: false, + }) + const result = await clientCatching.gql(introspectionQueryDocument).send() + if (!(result instanceof Error)) break + } } - } - // Send the query again with the host configuration for output. - // TODO rather than having to make this query again expose a way to send a value through the output handler here. - // TODO expose the featuresDropped info on the envelope so that upstream can communicate to users what happened - // finally at runtime. - return await client.gql(introspectionQueryDocument).send() - } - }), - }) -} + // Send the query again with the host configuration for output. + // TODO rather than having to make this query again expose a way to send a value through the output handler here. + // TODO expose the featuresDropped info on the envelope so that upstream can communicate to users what happened + // finally at runtime. + return await client.gql(introspectionQueryDocument).send() + } + }), + } + }, +}) interface BuilderExtension extends Builder.Extension { context: Context @@ -73,3 +75,5 @@ interface BuilderExtension extends Builder.Extension { interface BuilderExtension_<$Args extends Builder.Extension.Parameters> { introspect: () => Promise>> } + +const knownPotentiallyUnsupportedFeatures = [`inputValueDeprecation`, `oneOf`] as const diff --git a/src/extensions/Introspection/config.ts b/src/extensions/Introspection/config.ts index bed70d384..d77aadc73 100644 --- a/src/extensions/Introspection/config.ts +++ b/src/extensions/Introspection/config.ts @@ -1,7 +1,7 @@ import type { GraphQLSchema, IntrospectionOptions } from 'graphql' import type { InputIntrospectionOptions } from '../../generator/_.js' -export type Input = { +export type ConfigInput = { /** * The schema instance or endpoint to introspect. By default uses the value the client was constructed with. */ @@ -33,7 +33,7 @@ export const defaults = { }, } satisfies Config -export const createConfig = (input?: Input): Config => { +export const createConfig = (input?: ConfigInput): Config => { return { schema: input?.schema ?? defaults.schema, options: input?.options ?? defaults.options, diff --git a/src/extensions/Opentelemetry/Opentelemetry.ts b/src/extensions/Opentelemetry/Opentelemetry.ts index a5329fbe6..1255c70ee 100644 --- a/src/extensions/Opentelemetry/Opentelemetry.ts +++ b/src/extensions/Opentelemetry/Opentelemetry.ts @@ -1,27 +1,28 @@ import { trace, type Tracer } from '@opentelemetry/api' import { createExtension } from '../../extension/extension.js' -import { createConfig, type Input } from './config.js' +import { createConfig } from './config.js' -export const Opentelemetry = (input?: Input) => { - const config = createConfig(input) - const tracer = trace.getTracer(config.tracerName) - const startActiveGraffleSpan = startActiveSpan(tracer) - - return createExtension({ - name: `Opentelemetry`, - onRequest: async ({ encode }) => { - encode.input - return await startActiveGraffleSpan(`request`, async () => { - const { pack } = await startActiveGraffleSpan(`encode`, encode) - const { exchange } = await startActiveGraffleSpan(`pack`, pack) - const { unpack } = await startActiveGraffleSpan(`exchange`, exchange) - const { decode } = await startActiveGraffleSpan(`unpack`, unpack) - const result = await startActiveGraffleSpan(`decode`, decode) - return result - }) - }, - }) -} +export const Opentelemetry = createExtension({ + name: `Opentelemetry`, + normalizeConfig: createConfig, + create: ({ config }) => { + const tracer = trace.getTracer(config.tracerName) + const startActiveGraffleSpan = startActiveSpan(tracer) + return { + onRequest: async ({ encode }) => { + encode.input + return await startActiveGraffleSpan(`request`, async () => { + const { pack } = await startActiveGraffleSpan(`encode`, encode) + const { exchange } = await startActiveGraffleSpan(`pack`, pack) + const { unpack } = await startActiveGraffleSpan(`exchange`, exchange) + const { decode } = await startActiveGraffleSpan(`unpack`, unpack) + const result = await startActiveGraffleSpan(`decode`, decode) + return result + }) + }, + } + }, +}) const startActiveSpan = (tracer: Tracer) => (name: string, fn: () => Promise): Promise => { return tracer.startActiveSpan(name, async (span) => { diff --git a/src/extensions/SchemaErrors/runtime.ts b/src/extensions/SchemaErrors/runtime.ts index aa79c31d0..a191f70ba 100644 --- a/src/extensions/SchemaErrors/runtime.ts +++ b/src/extensions/SchemaErrors/runtime.ts @@ -7,72 +7,74 @@ import { SchemaDrivenDataMap } from '../../types/SchemaDrivenDataMap/__.js' import type { GeneratedExtensions } from './global.js' import { injectTypenameOnRootResultFields } from './injectTypenameOnRootResultFields.js' -export const SchemaErrors = () => { - return createExtension({ - name: `SchemaErrors`, - onRequest: async ({ pack }) => { - const state = pack.input.state - const sddm = state.schemaMap - - if (!sddm) return pack() - - const request = normalizeRequestToNode(pack.input.request) - - // We will mutate query. Assign it back to input for it to be carried forward. - pack.input.request.query = request.query - - injectTypenameOnRootResultFields({ sddm, request }) - - const { exchange } = await pack() - const { unpack } = await exchange() - const { decode } = await unpack() - const result = await decode() - - if (result instanceof Error || !result.data) return result - - const schemaErrors: Error[] = [] - for (const [rootFieldName, rootFieldValue] of Object.entries(result.data)) { - // todo this check would be nice but it doesn't account for aliases right now. To achieve this we would - // need to have the selection set available to use and then do a costly analysis for all fields that were aliases. - // So costly that we would probably instead want to create an index of them on the initial encoding step and - // then make available down stream. - // const sddmNodeField = sddm.roots[rootTypeName]?.f[rootFieldName] - // if (!sddmNodeField) return null - // if (!isPlainObject(rootFieldValue)) return new Error(`Expected result field to be an object.`) - if (!isRecordLikeObject(rootFieldValue)) continue - - // If __typename is not selected we assume that this is not a result field. - // The extension makes sure that the __typename would have been selected if it were a result field. - const __typename = rootFieldValue[`__typename`] - if (!isString(__typename)) continue - - const sddmNode = sddm.types[__typename] - const isErrorObject = SchemaDrivenDataMap.isOutputObject(sddmNode) && Boolean(sddmNode.e) - if (!isErrorObject) continue - - // todo extract message - // todo allow mapping error instances to schema errors - schemaErrors.push(new Error(`Failure on field ${rootFieldName}: ${__typename}`)) - } - - const error = (schemaErrors.length === 1) - ? schemaErrors[0]! - : schemaErrors.length > 0 - ? new Errors.ContextualAggregateError(`Two or more schema errors in the execution result.`, {}, schemaErrors) - : null - - if (error) { - result.errors = [...result.errors ?? [], error as any] - } - - return result - }, - typeHooks: createTypeHooks<{ - onRequestDocumentRootType: OnRequestDocumentRootType_ - onRequestResult: OnRequestResult_ - }>, - }) -} +export const SchemaErrors = createExtension({ + name: `SchemaErrors`, + create: () => { + return { + onRequest: async ({ pack }) => { + const state = pack.input.state + const sddm = state.schemaMap + + if (!sddm) return pack() + + const request = normalizeRequestToNode(pack.input.request) + + // We will mutate query. Assign it back to input for it to be carried forward. + pack.input.request.query = request.query + + injectTypenameOnRootResultFields({ sddm, request }) + + const { exchange } = await pack() + const { unpack } = await exchange() + const { decode } = await unpack() + const result = await decode() + + if (result instanceof Error || !result.data) return result + + const schemaErrors: Error[] = [] + for (const [rootFieldName, rootFieldValue] of Object.entries(result.data)) { + // todo this check would be nice but it doesn't account for aliases right now. To achieve this we would + // need to have the selection set available to use and then do a costly analysis for all fields that were aliases. + // So costly that we would probably instead want to create an index of them on the initial encoding step and + // then make available down stream. + // const sddmNodeField = sddm.roots[rootTypeName]?.f[rootFieldName] + // if (!sddmNodeField) return null + // if (!isPlainObject(rootFieldValue)) return new Error(`Expected result field to be an object.`) + if (!isRecordLikeObject(rootFieldValue)) continue + + // If __typename is not selected we assume that this is not a result field. + // The extension makes sure that the __typename would have been selected if it were a result field. + const __typename = rootFieldValue[`__typename`] + if (!isString(__typename)) continue + + const sddmNode = sddm.types[__typename] + const isErrorObject = SchemaDrivenDataMap.isOutputObject(sddmNode) && Boolean(sddmNode.e) + if (!isErrorObject) continue + + // todo extract message + // todo allow mapping error instances to schema errors + schemaErrors.push(new Error(`Failure on field ${rootFieldName}: ${__typename}`)) + } + + const error = (schemaErrors.length === 1) + ? schemaErrors[0]! + : schemaErrors.length > 0 + ? new Errors.ContextualAggregateError(`Two or more schema errors in the execution result.`, {}, schemaErrors) + : null + + if (error) { + result.errors = [...result.errors ?? [], error as any] + } + + return result + }, + typeHooks: createTypeHooks<{ + onRequestDocumentRootType: OnRequestDocumentRootType_ + onRequestResult: OnRequestResult_ + }>, + } + }, +}) type OnRequestDocumentRootType<$Params extends Extension.Hooks.OnRequestDocumentRootType.Params> = $Params['selectionRootType'] diff --git a/src/extensions/Throws/Throws.ts b/src/extensions/Throws/Throws.ts index 89cccdcca..df86423e2 100644 --- a/src/extensions/Throws/Throws.ts +++ b/src/extensions/Throws/Throws.ts @@ -5,29 +5,31 @@ import type { ConfigManager } from '../../lib/config-manager/__.js' import type { Context } from '../../layers/6_client/context.js' import type { Builder } from '../../lib/chain/__.js' -export const Throws = () => { - return createExtension({ - name: `Throws`, - builder: createBuilderExtension(({ client, property, path }) => { - if (property !== `throws` || path.length !== 0) return undefined +export const Throws = createExtension({ + name: `Throws`, + create: () => { + return { + builder: createBuilderExtension(({ client, property, path }) => { + if (property !== `throws` || path.length !== 0) return undefined - // todo redesign input to allow to force throw always - // todo pull pre-configured config from core - const throwsifiedInput: WithInput = { - output: { - envelope: { - enabled: client._.config.output.envelope.enabled, + // todo redesign input to allow to force throw always + // todo pull pre-configured config from core + const throwsifiedInput: WithInput = { + output: { + envelope: { + enabled: client._.config.output.envelope.enabled, + // @ts-expect-error + errors: { execution: false, other: false, schema: false }, + }, // @ts-expect-error - errors: { execution: false, other: false, schema: false }, + errors: { execution: `throw`, other: `throw`, schema: `throw` }, }, - // @ts-expect-error - errors: { execution: `throw`, other: `throw`, schema: `throw` }, - }, - } - return () => client.with(throwsifiedInput) - }), - }) -} + } + return () => client.with(throwsifiedInput) + }), + } + }, +}) interface BuilderExtension extends Builder.Extension { context: Context diff --git a/src/extensions/Upload/Upload.ts b/src/extensions/Upload/Upload.ts index 98d17dd63..1b50f23b1 100644 --- a/src/extensions/Upload/Upload.ts +++ b/src/extensions/Upload/Upload.ts @@ -5,46 +5,49 @@ import { createBody } from './createBody.js' /** * @see https://github.com/jaydenseric/graphql-multipart-request-spec */ -export const Upload = () => - createExtension({ - name: `Upload`, - onRequest: async ({ pack }) => { - // TODO we can probably get file upload working for in-memory schemas too :) - if (pack.input.transportType !== `http`) { - throw new Error(`Must be using http transport to use "Upload" scalar.`) - } +export const Upload = createExtension({ + name: `Upload`, + create: () => { + return { + onRequest: async ({ pack }) => { + // TODO we can probably get file upload working for in-memory schemas too :) + if (pack.input.transportType !== `http`) { + throw new Error(`Must be using http transport to use "Upload" scalar.`) + } - // Remove the content-type header so that fetch sets it automatically upon seeing the body is a FormData instance. - // @see https://muffinman.io/blog/uploading-files-using-fetch-multipart-form-data/ - // @see https://stackoverflow.com/questions/3508338/what-is-the-boundary-in-multipart-form-data - // @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition - return await pack({ - // todo rename "using" to "with" - using: { - body: (input) => { - const hasUploadScalarVariable = input.variables && isUsingUploadScalar(input.variables) - if (!hasUploadScalarVariable) return + // Remove the content-type header so that fetch sets it automatically upon seeing the body is a FormData instance. + // @see https://muffinman.io/blog/uploading-files-using-fetch-multipart-form-data/ + // @see https://stackoverflow.com/questions/3508338/what-is-the-boundary-in-multipart-form-data + // @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition + return await pack({ + // todo rename "using" to "with" + using: { + body: (input) => { + const hasUploadScalarVariable = input.variables && isUsingUploadScalar(input.variables) + if (!hasUploadScalarVariable) return - // TODO we can probably get file upload working for in-memory schemas too :) - if (pack.input.transportType !== `http`) { - throw new Error(`Must be using http transport to use "Upload" scalar.`) - } + // TODO we can probably get file upload working for in-memory schemas too :) + if (pack.input.transportType !== `http`) { + throw new Error(`Must be using http transport to use "Upload" scalar.`) + } - return createBody({ - query: input.query, - variables: input.variables!, - }) + return createBody({ + query: input.query, + variables: input.variables!, + }) + }, }, - }, - input: { - ...pack.input, - headers: { - 'content-type': ``, + input: { + ...pack.input, + headers: { + 'content-type': ``, + }, }, - }, - }) - }, - }) + }) + }, + } + }, +}) const isUsingUploadScalar = (_variables: Variables) => { return Object.values(_variables).some(_ => _ instanceof Blob) diff --git a/src/layers/6_client/chainExtensions/anyware.ts b/src/layers/6_client/chainExtensions/anyware.ts index 931c25da4..0dfe0e435 100644 --- a/src/layers/6_client/chainExtensions/anyware.ts +++ b/src/layers/6_client/chainExtensions/anyware.ts @@ -24,7 +24,13 @@ export const AnywareExtension = Builder.Extension.create((builder, con anyware: (anyware: Anyware.Extension2) => { return builder({ ...context, - extensions: [...context.extensions, createExtension({ name: `InlineAnyware`, onRequest: anyware })], + extensions: [ + ...context.extensions, + createExtension({ + name: `InlineAnyware`, + create: () => ({ onRequest: anyware }), + })(), + ], }) }, } diff --git a/src/lib/prelude.test-d.ts b/src/lib/prelude.test-d.ts index af74c6663..a856db74d 100644 --- a/src/lib/prelude.test-d.ts +++ b/src/lib/prelude.test-d.ts @@ -1,5 +1,5 @@ import { assertEqual } from './assert-equal.js' -import type { OmitKeysWithPrefix } from './prelude.js' +import type { OmitKeysWithPrefix, ToParameters } from './prelude.js' // dprint-ignore { @@ -7,4 +7,10 @@ import type { OmitKeysWithPrefix } from './prelude.js' assertEqual , { a: 1; b: 2 }>() assertEqual , { b: 2 }>() +assertEqual , [{ a:1 }]>() +assertEqual , [{ a?:1 }]|[]>() +assertEqual , []>() +assertEqual , [{ a:1; b?:2 }]>() +assertEqual , [{ a?:1; b?:2 }]|[]>() + } diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index c76bd2071..b77436ffe 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -1,4 +1,4 @@ -import type { IsAny, IsEmptyObject, IsNever, IsUnknown, Simplify } from 'type-fest' +import type { HasRequiredKeys, IsAny, IsEmptyObject, IsNever, IsUnknown, Simplify } from 'type-fest' /* eslint-disable */ export type RemoveIndex = { @@ -638,3 +638,11 @@ export type SimplifyExcept<$ExcludeType, $Type> = : {[TypeKey in keyof $Type]: $Type[TypeKey]} export const any = undefined as any + +// dprint-ignore +export type ToParameters<$Params extends object | undefined> = + $Params extends object + ? HasKeys<$Params> extends false ? [] : + HasRequiredKeys<$Params> extends true ? [$Params] : + [$Params] | [] + : [] diff --git a/tests/_/SpyExtension.ts b/tests/_/SpyExtension.ts index 8586dca11..1a642ffae 100644 --- a/tests/_/SpyExtension.ts +++ b/tests/_/SpyExtension.ts @@ -3,19 +3,6 @@ import { createExtension } from '../../src/entrypoints/main.js' import type { Config } from '../../src/entrypoints/utilities-for-generated.js' import type { HookDefEncode, HookDefExchange, HookDefPack } from '../../src/requestPipeline/hooks.js' -export const Spy = () => - createExtension({ - name: `Spy`, - onRequest: async ({ encode }) => { - Spy.data.encode.input = encode.input - const { pack } = await encode() - Spy.data.pack.input = pack.input - const { exchange } = await pack() - Spy.data.exchange.input = exchange.input - return exchange() - }, - }) - interface SpyData { encode: { input: HookDefEncode['input'] | null @@ -40,7 +27,24 @@ const emptySpyData: SpyData = { }, } -Spy.data = emptySpyData +export const Spy = createExtension({ + name: `Spy`, + custom: { + data: emptySpyData, + }, + create: () => { + return { + onRequest: async ({ encode }) => { + Spy.data.encode.input = encode.input + const { pack } = await encode() + Spy.data.pack.input = pack.input + const { exchange } = await pack() + Spy.data.exchange.input = exchange.input + return exchange() + }, + } + }, +}) beforeEach(() => { Spy.data = emptySpyData