Skip to content

Commit

Permalink
feat(extension): integrate config concept (#1247)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonkuhrt authored Nov 1, 2024
1 parent b5af608 commit e67dab0
Show file tree
Hide file tree
Showing 11 changed files with 273 additions and 206 deletions.
45 changes: 38 additions & 7 deletions src/extension/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -44,13 +44,15 @@ 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 {
/**
* The name of the extension
*/
name: $Name
config: $Config
/**
* Anyware executed on every request.
*/
Expand Down Expand Up @@ -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<RequestPipeline.Core>
typeHooks?: () => $TypeHooks
normalizeConfig?: (input?: $ConfigInput) => $Config
custom?: $Custom
create: (params: { config: $Config }) => {
builder?: $BuilderExtension
onRequest?: Anyware.Extension2<RequestPipeline.Core>
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
80 changes: 42 additions & 38 deletions src/extensions/Introspection/Introspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<BuilderExtension>(({ 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<BuilderExtension>(({ 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
Expand All @@ -73,3 +75,5 @@ interface BuilderExtension extends Builder.Extension {
interface BuilderExtension_<$Args extends Builder.Extension.Parameters<BuilderExtension>> {
introspect: () => Promise<SimplifyNullable<HandleOutput<$Args['context'], IntrospectionQuery>>>
}

const knownPotentiallyUnsupportedFeatures = [`inputValueDeprecation`, `oneOf`] as const
4 changes: 2 additions & 2 deletions src/extensions/Introspection/config.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 22 additions & 21 deletions src/extensions/Opentelemetry/Opentelemetry.ts
Original file line number Diff line number Diff line change
@@ -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) => <Result>(name: string, fn: () => Promise<Result>): Promise<Result> => {
return tracer.startActiveSpan(name, async (span) => {
Expand Down
134 changes: 68 additions & 66 deletions src/extensions/SchemaErrors/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
Loading

0 comments on commit e67dab0

Please sign in to comment.