Skip to content

Commit e67dab0

Browse files
authored
feat(extension): integrate config concept (#1247)
1 parent b5af608 commit e67dab0

File tree

11 files changed

+273
-206
lines changed

11 files changed

+273
-206
lines changed

src/extension/extension.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Context } from '../layers/6_client/context.js'
44
import type { GraffleExecutionResultEnvelope } from '../layers/6_client/handleOutput.js'
55
import type { Anyware } from '../lib/anyware/__.js'
66
import type { Builder } from '../lib/chain/__.js'
7-
import type { AssertExtends } from '../lib/prelude.js'
7+
import type { AssertExtends, ToParameters } from '../lib/prelude.js'
88
import type { TypeFunction } from '../lib/type-function/__.js'
99
import type { Fn } from '../lib/type-function/TypeFunction.js'
1010
import type { RequestPipeline } from '../requestPipeline/__.js'
@@ -44,13 +44,15 @@ export interface EmptyTypeHooks {
4444

4545
export interface Extension<
4646
$Name extends string = string,
47+
$Config extends object | undefined = object | undefined,
4748
$BuilderExtension extends BuilderExtension | undefined = BuilderExtension | undefined,
4849
$TypeHooks extends TypeHooks = TypeHooks,
4950
> extends Fn {
5051
/**
5152
* The name of the extension
5253
*/
5354
name: $Name
55+
config: $Config
5456
/**
5557
* Anyware executed on every request.
5658
*/
@@ -135,13 +137,42 @@ export const createExtension = <
135137
$Name extends string,
136138
$BuilderExtension extends BuilderExtension | undefined = undefined,
137139
$TypeHooks extends TypeHooks = TypeHooks,
140+
$ConfigInput extends object = object,
141+
$Config extends object = object,
142+
$Custom extends object = object,
138143
>(
139-
extension: {
144+
extensionInput: {
140145
name: $Name
141-
builder?: $BuilderExtension
142-
onRequest?: Anyware.Extension2<RequestPipeline.Core>
143-
typeHooks?: () => $TypeHooks
146+
normalizeConfig?: (input?: $ConfigInput) => $Config
147+
custom?: $Custom
148+
create: (params: { config: $Config }) => {
149+
builder?: $BuilderExtension
150+
onRequest?: Anyware.Extension2<RequestPipeline.Core>
151+
typeHooks?: () => $TypeHooks
152+
}
144153
},
145-
): Extension<$Name, $BuilderExtension, $TypeHooks> => {
146-
return extension as any
154+
): ExtensionConstructor<
155+
$ConfigInput,
156+
$Config,
157+
$Name,
158+
$BuilderExtension,
159+
$TypeHooks,
160+
$Custom
161+
> => {
162+
const extensionConstructor = (input: any) => {
163+
const config = (extensionInput.normalizeConfig?.(input) ?? {}) as any
164+
return extensionInput.create({ config }) as any
165+
}
166+
return extensionConstructor as any
147167
}
168+
169+
export type ExtensionConstructor<
170+
$ConfigInput extends undefined | object,
171+
$Config extends object,
172+
$Name extends string,
173+
$BuilderExtension extends BuilderExtension | undefined = undefined,
174+
$TypeHooks extends TypeHooks = TypeHooks,
175+
$Custom extends object = object,
176+
> =
177+
& ((...args: ToParameters<$ConfigInput>) => Extension<$Name, $Config, $BuilderExtension, $TypeHooks>)
178+
& $Custom

src/extensions/Introspection/Introspection.ts

Lines changed: 42 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@ import type { SimplifyNullable } from '../../entrypoints/main.js'
44
import type { Context } from '../../layers/6_client/context.js'
55
import type { HandleOutput } from '../../layers/6_client/handleOutput.js'
66
import type { Builder } from '../../lib/chain/__.js'
7-
import { createConfig, type Input } from './config.js'
8-
9-
const knownPotentiallyUnsupportedFeatures = [`inputValueDeprecation`, `oneOf`] as const
7+
import { type ConfigInput, createConfig } from './config.js'
108

119
/**
1210
* This extension adds a `.introspect` method to the client that will return the introspected schema.
@@ -24,45 +22,49 @@ const knownPotentiallyUnsupportedFeatures = [`inputValueDeprecation`, `oneOf`] a
2422
* const data = await graffle.introspect()
2523
* ```
2624
*/
27-
export const Introspection = (input?: Input) => {
28-
const config = createConfig(input)
25+
export const Introspection = createExtension({
26+
name: `Introspection`,
27+
normalizeConfig: (input?: ConfigInput) => {
28+
const config = createConfig(input)
29+
return config
30+
},
31+
create: ({ config }) => {
32+
return {
33+
builder: createBuilderExtension<BuilderExtension>(({ path, property, client }) => {
34+
if (!(path.length === 0 && property === `introspect`)) return
35+
const clientCatching = client.with({ output: { envelope: false, errors: { execution: `return` } } })
2936

30-
return createExtension({
31-
name: `Introspection`,
32-
builder: createBuilderExtension<BuilderExtension>(({ path, property, client }) => {
33-
if (!(path.length === 0 && property === `introspect`)) return
34-
const clientCatching = client.with({ output: { envelope: false, errors: { execution: `return` } } })
37+
return async () => {
38+
let introspectionQueryDocument = getIntrospectionQuery(config.options)
39+
const result = await clientCatching.gql(introspectionQueryDocument).send()
40+
const featuresDropped: string[] = []
41+
const enabledKnownPotentiallyUnsupportedFeatures = knownPotentiallyUnsupportedFeatures.filter(_ =>
42+
config.options[_] !== false
43+
)
3544

36-
return async () => {
37-
let introspectionQueryDocument = getIntrospectionQuery(config.options)
38-
const result = await clientCatching.gql(introspectionQueryDocument).send()
39-
const featuresDropped: string[] = []
40-
const enabledKnownPotentiallyUnsupportedFeatures = knownPotentiallyUnsupportedFeatures.filter(_ =>
41-
config.options[_] !== false
42-
)
43-
44-
// Try to find a working introspection query.
45-
if (result instanceof Error) {
46-
for (const feature of enabledKnownPotentiallyUnsupportedFeatures) {
47-
featuresDropped.push(feature)
48-
introspectionQueryDocument = getIntrospectionQuery({
49-
...config.options,
50-
[feature]: false,
51-
})
52-
const result = await clientCatching.gql(introspectionQueryDocument).send()
53-
if (!(result instanceof Error)) break
45+
// Try to find a working introspection query.
46+
if (result instanceof Error) {
47+
for (const feature of enabledKnownPotentiallyUnsupportedFeatures) {
48+
featuresDropped.push(feature)
49+
introspectionQueryDocument = getIntrospectionQuery({
50+
...config.options,
51+
[feature]: false,
52+
})
53+
const result = await clientCatching.gql(introspectionQueryDocument).send()
54+
if (!(result instanceof Error)) break
55+
}
5456
}
55-
}
5657

57-
// Send the query again with the host configuration for output.
58-
// TODO rather than having to make this query again expose a way to send a value through the output handler here.
59-
// TODO expose the featuresDropped info on the envelope so that upstream can communicate to users what happened
60-
// finally at runtime.
61-
return await client.gql(introspectionQueryDocument).send()
62-
}
63-
}),
64-
})
65-
}
58+
// Send the query again with the host configuration for output.
59+
// TODO rather than having to make this query again expose a way to send a value through the output handler here.
60+
// TODO expose the featuresDropped info on the envelope so that upstream can communicate to users what happened
61+
// finally at runtime.
62+
return await client.gql(introspectionQueryDocument).send()
63+
}
64+
}),
65+
}
66+
},
67+
})
6668

6769
interface BuilderExtension extends Builder.Extension {
6870
context: Context
@@ -73,3 +75,5 @@ interface BuilderExtension extends Builder.Extension {
7375
interface BuilderExtension_<$Args extends Builder.Extension.Parameters<BuilderExtension>> {
7476
introspect: () => Promise<SimplifyNullable<HandleOutput<$Args['context'], IntrospectionQuery>>>
7577
}
78+
79+
const knownPotentiallyUnsupportedFeatures = [`inputValueDeprecation`, `oneOf`] as const

src/extensions/Introspection/config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { GraphQLSchema, IntrospectionOptions } from 'graphql'
22
import type { InputIntrospectionOptions } from '../../generator/_.js'
33

4-
export type Input = {
4+
export type ConfigInput = {
55
/**
66
* The schema instance or endpoint to introspect. By default uses the value the client was constructed with.
77
*/
@@ -33,7 +33,7 @@ export const defaults = {
3333
},
3434
} satisfies Config
3535

36-
export const createConfig = (input?: Input): Config => {
36+
export const createConfig = (input?: ConfigInput): Config => {
3737
return {
3838
schema: input?.schema ?? defaults.schema,
3939
options: input?.options ?? defaults.options,

src/extensions/Opentelemetry/Opentelemetry.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
import { trace, type Tracer } from '@opentelemetry/api'
22
import { createExtension } from '../../extension/extension.js'
3-
import { createConfig, type Input } from './config.js'
3+
import { createConfig } from './config.js'
44

5-
export const Opentelemetry = (input?: Input) => {
6-
const config = createConfig(input)
7-
const tracer = trace.getTracer(config.tracerName)
8-
const startActiveGraffleSpan = startActiveSpan(tracer)
9-
10-
return createExtension({
11-
name: `Opentelemetry`,
12-
onRequest: async ({ encode }) => {
13-
encode.input
14-
return await startActiveGraffleSpan(`request`, async () => {
15-
const { pack } = await startActiveGraffleSpan(`encode`, encode)
16-
const { exchange } = await startActiveGraffleSpan(`pack`, pack)
17-
const { unpack } = await startActiveGraffleSpan(`exchange`, exchange)
18-
const { decode } = await startActiveGraffleSpan(`unpack`, unpack)
19-
const result = await startActiveGraffleSpan(`decode`, decode)
20-
return result
21-
})
22-
},
23-
})
24-
}
5+
export const Opentelemetry = createExtension({
6+
name: `Opentelemetry`,
7+
normalizeConfig: createConfig,
8+
create: ({ config }) => {
9+
const tracer = trace.getTracer(config.tracerName)
10+
const startActiveGraffleSpan = startActiveSpan(tracer)
11+
return {
12+
onRequest: async ({ encode }) => {
13+
encode.input
14+
return await startActiveGraffleSpan(`request`, async () => {
15+
const { pack } = await startActiveGraffleSpan(`encode`, encode)
16+
const { exchange } = await startActiveGraffleSpan(`pack`, pack)
17+
const { unpack } = await startActiveGraffleSpan(`exchange`, exchange)
18+
const { decode } = await startActiveGraffleSpan(`unpack`, unpack)
19+
const result = await startActiveGraffleSpan(`decode`, decode)
20+
return result
21+
})
22+
},
23+
}
24+
},
25+
})
2526

2627
const startActiveSpan = (tracer: Tracer) => <Result>(name: string, fn: () => Promise<Result>): Promise<Result> => {
2728
return tracer.startActiveSpan(name, async (span) => {

src/extensions/SchemaErrors/runtime.ts

Lines changed: 68 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -7,72 +7,74 @@ import { SchemaDrivenDataMap } from '../../types/SchemaDrivenDataMap/__.js'
77
import type { GeneratedExtensions } from './global.js'
88
import { injectTypenameOnRootResultFields } from './injectTypenameOnRootResultFields.js'
99

10-
export const SchemaErrors = () => {
11-
return createExtension({
12-
name: `SchemaErrors`,
13-
onRequest: async ({ pack }) => {
14-
const state = pack.input.state
15-
const sddm = state.schemaMap
16-
17-
if (!sddm) return pack()
18-
19-
const request = normalizeRequestToNode(pack.input.request)
20-
21-
// We will mutate query. Assign it back to input for it to be carried forward.
22-
pack.input.request.query = request.query
23-
24-
injectTypenameOnRootResultFields({ sddm, request })
25-
26-
const { exchange } = await pack()
27-
const { unpack } = await exchange()
28-
const { decode } = await unpack()
29-
const result = await decode()
30-
31-
if (result instanceof Error || !result.data) return result
32-
33-
const schemaErrors: Error[] = []
34-
for (const [rootFieldName, rootFieldValue] of Object.entries(result.data)) {
35-
// todo this check would be nice but it doesn't account for aliases right now. To achieve this we would
36-
// need to have the selection set available to use and then do a costly analysis for all fields that were aliases.
37-
// So costly that we would probably instead want to create an index of them on the initial encoding step and
38-
// then make available down stream.
39-
// const sddmNodeField = sddm.roots[rootTypeName]?.f[rootFieldName]
40-
// if (!sddmNodeField) return null
41-
// if (!isPlainObject(rootFieldValue)) return new Error(`Expected result field to be an object.`)
42-
if (!isRecordLikeObject(rootFieldValue)) continue
43-
44-
// If __typename is not selected we assume that this is not a result field.
45-
// The extension makes sure that the __typename would have been selected if it were a result field.
46-
const __typename = rootFieldValue[`__typename`]
47-
if (!isString(__typename)) continue
48-
49-
const sddmNode = sddm.types[__typename]
50-
const isErrorObject = SchemaDrivenDataMap.isOutputObject(sddmNode) && Boolean(sddmNode.e)
51-
if (!isErrorObject) continue
52-
53-
// todo extract message
54-
// todo allow mapping error instances to schema errors
55-
schemaErrors.push(new Error(`Failure on field ${rootFieldName}: ${__typename}`))
56-
}
57-
58-
const error = (schemaErrors.length === 1)
59-
? schemaErrors[0]!
60-
: schemaErrors.length > 0
61-
? new Errors.ContextualAggregateError(`Two or more schema errors in the execution result.`, {}, schemaErrors)
62-
: null
63-
64-
if (error) {
65-
result.errors = [...result.errors ?? [], error as any]
66-
}
67-
68-
return result
69-
},
70-
typeHooks: createTypeHooks<{
71-
onRequestDocumentRootType: OnRequestDocumentRootType_
72-
onRequestResult: OnRequestResult_
73-
}>,
74-
})
75-
}
10+
export const SchemaErrors = createExtension({
11+
name: `SchemaErrors`,
12+
create: () => {
13+
return {
14+
onRequest: async ({ pack }) => {
15+
const state = pack.input.state
16+
const sddm = state.schemaMap
17+
18+
if (!sddm) return pack()
19+
20+
const request = normalizeRequestToNode(pack.input.request)
21+
22+
// We will mutate query. Assign it back to input for it to be carried forward.
23+
pack.input.request.query = request.query
24+
25+
injectTypenameOnRootResultFields({ sddm, request })
26+
27+
const { exchange } = await pack()
28+
const { unpack } = await exchange()
29+
const { decode } = await unpack()
30+
const result = await decode()
31+
32+
if (result instanceof Error || !result.data) return result
33+
34+
const schemaErrors: Error[] = []
35+
for (const [rootFieldName, rootFieldValue] of Object.entries(result.data)) {
36+
// todo this check would be nice but it doesn't account for aliases right now. To achieve this we would
37+
// need to have the selection set available to use and then do a costly analysis for all fields that were aliases.
38+
// So costly that we would probably instead want to create an index of them on the initial encoding step and
39+
// then make available down stream.
40+
// const sddmNodeField = sddm.roots[rootTypeName]?.f[rootFieldName]
41+
// if (!sddmNodeField) return null
42+
// if (!isPlainObject(rootFieldValue)) return new Error(`Expected result field to be an object.`)
43+
if (!isRecordLikeObject(rootFieldValue)) continue
44+
45+
// If __typename is not selected we assume that this is not a result field.
46+
// The extension makes sure that the __typename would have been selected if it were a result field.
47+
const __typename = rootFieldValue[`__typename`]
48+
if (!isString(__typename)) continue
49+
50+
const sddmNode = sddm.types[__typename]
51+
const isErrorObject = SchemaDrivenDataMap.isOutputObject(sddmNode) && Boolean(sddmNode.e)
52+
if (!isErrorObject) continue
53+
54+
// todo extract message
55+
// todo allow mapping error instances to schema errors
56+
schemaErrors.push(new Error(`Failure on field ${rootFieldName}: ${__typename}`))
57+
}
58+
59+
const error = (schemaErrors.length === 1)
60+
? schemaErrors[0]!
61+
: schemaErrors.length > 0
62+
? new Errors.ContextualAggregateError(`Two or more schema errors in the execution result.`, {}, schemaErrors)
63+
: null
64+
65+
if (error) {
66+
result.errors = [...result.errors ?? [], error as any]
67+
}
68+
69+
return result
70+
},
71+
typeHooks: createTypeHooks<{
72+
onRequestDocumentRootType: OnRequestDocumentRootType_
73+
onRequestResult: OnRequestResult_
74+
}>,
75+
}
76+
},
77+
})
7678

7779
type OnRequestDocumentRootType<$Params extends Extension.Hooks.OnRequestDocumentRootType.Params> =
7880
$Params['selectionRootType']

0 commit comments

Comments
 (0)