Skip to content

Commit

Permalink
feat(extension): support inline type extensions (#1243)
Browse files Browse the repository at this point in the history
This PR makes it possible for extension authors to inline their
type extensions into the extension body. We achieve this with a new
explicit typeHooks field in the constructor input as well as a new
phantom property on builder property to supply types along with a helper
sub-constructor for hiding this nested phantom property.
  • Loading branch information
jasonkuhrt authored Oct 31, 2024
1 parent 5c629fb commit 27b4da7
Show file tree
Hide file tree
Showing 18 changed files with 164 additions and 136 deletions.
2 changes: 1 addition & 1 deletion src/entrypoints/extensionkit.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { createExtension } from '../extension/extension.js'
export { createBuilderExtension, createExtension, createTypeHooks } from '../extension/extension.js'
export { createExtension as createGeneratorExtension } from '../generator/extension/create.js'
77 changes: 57 additions & 20 deletions src/extension/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,14 @@ import type { Client } from '../layers/6_client/client.js'
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 { Chain } from '../lib/chain/__.js'
import type { Builder } from '../lib/chain/__.js'
import type { AssertExtends } 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'
import type { GlobalRegistry } from '../types/GlobalRegistry/GlobalRegistry.js'

export interface TypeHooks {
/**
* Extend chaining interface with new methods.
*/
chainExtension?: Chain.Extension
/**
* Manipulate the execution result of a request.
*
Expand Down Expand Up @@ -42,21 +38,30 @@ export type RunTypeHookOnRequestResult<
>

export interface EmptyTypeHooks {
property: undefined
onRequestResult: undefined
onRequestDocumentRootType: undefined
}

export interface Extension<$TypeHooks extends TypeHooks = TypeHooks> extends Fn {
export interface Extension<
$Name extends string = string,
$BuilderExtension extends BuilderExtension | undefined = BuilderExtension | undefined,
$TypeHooks extends TypeHooks = TypeHooks,
> extends Fn {
/**
* The name of the extension
*/
name: string
name: $Name
/**
* Anyware executed on every request.
*/
onRequest?: Anyware.Extension2<RequestPipeline.Core>
/**
* Manipulate the builder.
* You can extend the builder with new properties at both runtime AND buildtime (types, TypeScript).
* You can also manipulate existing properties.
*
* ### Runtime
*
* Hook into "get" events on the builder proxy. Useful for adding new methods or manipulating existing ones.
*
* Invoked when a non-record-like-object is reached. For example these:
Expand All @@ -70,14 +75,12 @@ export interface Extension<$TypeHooks extends TypeHooks = TypeHooks> extends Fn
*
* When there are multiple extensions with "onBuilderGet" handlers they form a execution stack starting from the first registered extension.
* The first handler to return something short circuits the rest.
*
* ### Types
*
* There is a type parameter you can pass in which will statically extend the builder.
*/
onBuilderGet?: (
input: {
path: string[]
property: string
client: Client<Context>
},
) => unknown
builder: $BuilderExtension
/**
* TODO
*/
Expand All @@ -102,9 +105,43 @@ export namespace Extension {
}
}

export const createExtension = <$Extension extends Extension = Extension<EmptyTypeHooks>>(
// type hooks
extension: Omit<TypeFunction.UnFn<$Extension>, 'typeHooks'>,
): $Extension => {
return extension as $Extension
export const createTypeHooks = <$TypeHooks extends TypeHooks = TypeHooks>(): $TypeHooks => {
return undefined as any as $TypeHooks
}

export const createBuilderExtension = <$BuilderExtension extends Builder.Extension | undefined = undefined>(
implementation: BuilderExtensionImplementation,
): BuilderExtension<$BuilderExtension> => {
return {
implementation,
} as BuilderExtension<$BuilderExtension>
}

export type BuilderExtension<$BuilderExtension extends Builder.Extension | undefined = Builder.Extension | undefined> =
{
type: $BuilderExtension
implementation: BuilderExtensionImplementation
}

export type BuilderExtensionImplementation = (
input: {
path: string[]
property: string
client: Client<Context>
},
) => unknown

export const createExtension = <
$Name extends string,
$BuilderExtension extends BuilderExtension | undefined = undefined,
$TypeHooks extends TypeHooks = TypeHooks,
>(
extension: {
name: $Name
builder?: $BuilderExtension
onRequest?: Anyware.Extension2<RequestPipeline.Core>
typeHooks?: () => $TypeHooks
},
): Extension<$Name, $BuilderExtension, $TypeHooks> => {
return extension as any
}
22 changes: 9 additions & 13 deletions src/extensions/Introspection/Introspection.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { getIntrospectionQuery, type IntrospectionQuery } from 'graphql'
import type { Extension, SimplifyNullable } from '../../entrypoints/main.js'
import { createExtension } from '../../extension/extension.js'
import { createBuilderExtension, createExtension } from '../../entrypoints/extensionkit.js'
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 { Chain } from '../../lib/chain/__.js'
import type { Builder } from '../../lib/chain/__.js'
import { createConfig, type Input } from './config.js'

const knownPotentiallyUnsupportedFeatures = [`inputValueDeprecation`, `oneOf`] as const
Expand All @@ -27,9 +27,9 @@ const knownPotentiallyUnsupportedFeatures = [`inputValueDeprecation`, `oneOf`] a
export const Introspection = (input?: Input) => {
const config = createConfig(input)

return createExtension<IntrospectionExtension>({
return createExtension({
name: `Introspection`,
onBuilderGet: ({ path, property, client }) => {
builder: createBuilderExtension<BuilderExtension>(({ path, property, client }) => {
if (!(path.length === 0 && property === `introspect`)) return
const clientCatching = client.with({ output: { envelope: false, errors: { execution: `return` } } })

Expand Down Expand Up @@ -60,20 +60,16 @@ export const Introspection = (input?: Input) => {
// finally at runtime.
return await client.gql(introspectionQueryDocument).send()
}
},
}),
})
}

type IntrospectionExtension = Extension<{
chainExtension: Introspect_
}>

interface Introspect_ extends Chain.Extension {
interface BuilderExtension extends Builder.Extension {
context: Context
// @ts-expect-error untyped params
return: Introspect<this['params']>
return: BuilderExtension_<this['params']>
}

interface Introspect<$Args extends Chain.Extension.Parameters<Introspect_>> {
interface BuilderExtension_<$Args extends Builder.Extension.Parameters<BuilderExtension>> {
introspect: () => Promise<SimplifyNullable<HandleOutput<$Args['context'], IntrospectionQuery>>>
}
13 changes: 6 additions & 7 deletions src/extensions/SchemaErrors/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createExtension, type Extension } from '../../extension/extension.js'
import { createExtension, createTypeHooks, type Extension } from '../../extension/extension.js'
import { Errors } from '../../lib/errors/__.js'
import { normalizeRequestToNode } from '../../lib/grafaid/request.js'
import { type ExcludeNullAndUndefined, isString } from '../../lib/prelude.js'
Expand All @@ -8,7 +8,7 @@ import type { GeneratedExtensions } from './global.js'
import { injectTypenameOnRootResultFields } from './injectTypenameOnRootResultFields.js'

export const SchemaErrors = () => {
return createExtension<SchemaErrorsExtension>({
return createExtension({
name: `SchemaErrors`,
onRequest: async ({ pack }) => {
const state = pack.input.state
Expand Down Expand Up @@ -67,14 +67,13 @@ export const SchemaErrors = () => {

return result
},
typeHooks: createTypeHooks<{
onRequestDocumentRootType: OnRequestDocumentRootType_
onRequestResult: OnRequestResult_
}>,
})
}

type SchemaErrorsExtension = Extension<{
onRequestDocumentRootType: OnRequestDocumentRootType_
onRequestResult: OnRequestResult_
}>

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

Expand Down
27 changes: 9 additions & 18 deletions src/extensions/Throws/Throws.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import {
type AssertExtends,
type BuilderConfig,
createExtension,
type Extension,
type WithInput,
} from '../../entrypoints/main.js'
import { createBuilderExtension, createExtension } from '../../entrypoints/extensionkit.js'
import { type AssertExtends, type BuilderConfig, type WithInput } from '../../entrypoints/main.js'
import type { ConfigManager } from '../../lib/config-manager/__.js'
// todo: no deep imports, rethink these utilities and/or how they are exported from the graffle package.
import type { Context } from '../../layers/6_client/context.js'
import type { Chain } from '../../lib/chain/__.js'
import type { Builder } from '../../lib/chain/__.js'

export const Throws = () => {
return createExtension<ThrowsExtension>({
return createExtension({
name: `Throws`,
onBuilderGet: ({ client, property, path }) => {
builder: createBuilderExtension<BuilderExtension>(({ client, property, path }) => {
if (property !== `throws` || path.length !== 0) return undefined

// todo redesign input to allow to force throw always
Expand All @@ -30,20 +25,16 @@ export const Throws = () => {
},
}
return () => client.with(throwsifiedInput)
},
}),
})
}

type ThrowsExtension = Extension<{
chainExtension: Throws_
}>

interface Throws_ extends Chain.Extension {
interface BuilderExtension extends Builder.Extension {
context: Context
return: Throws<AssertExtends<this['params'], Chain.Extension.Parameters<Throws_>>>
return: BuilderExtension_<AssertExtends<this['params'], Builder.Extension.Parameters<BuilderExtension>>>
}

interface Throws<$Args extends Chain.Extension.Parameters<Throws_>> {
interface BuilderExtension_<$Args extends Builder.Extension.Parameters<BuilderExtension>> {
/**
* TODO
*/
Expand Down
20 changes: 10 additions & 10 deletions src/layers/6_client/Settings/InputToConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,24 @@ export type NormalizeInput<$Input extends InputStatic> = {
transport: HandleTransport<$Input>
output: {
defaults: {
errorChannel: ConfigManager.ReadOrDefault<$Input, ['output', 'defaults', 'errorChannel'], 'throw'>
errorChannel: ConfigManager.GetAtPathOrDefault<$Input, ['output', 'defaults', 'errorChannel'], 'throw'>
}
envelope: {
enabled:
ConfigManager.Read<$Input, ['output','envelope']> extends boolean ? ConfigManager.Read<$Input, ['output','envelope']>
: ConfigManager.Read<$Input, ['output','envelope','enabled']> extends boolean ? ConfigManager.Read<$Input, ['output','envelope','enabled']>
: ConfigManager.Read<$Input, ['output','envelope']> extends object ? true
ConfigManager.GetOptional<$Input, ['output','envelope']> extends boolean ? ConfigManager.GetOptional<$Input, ['output','envelope']>
: ConfigManager.GetOptional<$Input, ['output','envelope','enabled']> extends boolean ? ConfigManager.GetOptional<$Input, ['output','envelope','enabled']>
: ConfigManager.GetOptional<$Input, ['output','envelope']> extends object ? true
: false
errors: {
execution: ConfigManager.ReadOrDefault<$Input, ['output','envelope','errors','execution'], true>
other: ConfigManager.ReadOrDefault<$Input, ['output','envelope','errors','other'], false>
schema: ConfigManager.ReadOrDefault<$Input, ['output','envelope','errors','schema'], false>
execution: ConfigManager.GetAtPathOrDefault<$Input, ['output','envelope','errors','execution'], true>
other: ConfigManager.GetAtPathOrDefault<$Input, ['output','envelope','errors','other'], false>
schema: ConfigManager.GetAtPathOrDefault<$Input, ['output','envelope','errors','schema'], false>
}
}
errors: {
execution: ConfigManager.ReadOrDefault<$Input,['output', 'errors', 'execution'], 'default'>
other: ConfigManager.ReadOrDefault<$Input,['output', 'errors', 'other'], 'default'>
schema: ConfigManager.ReadOrDefault<$Input,['output', 'errors', 'schema'], false>
execution: ConfigManager.GetAtPathOrDefault<$Input,['output', 'errors', 'execution'], 'default'>
other: ConfigManager.GetAtPathOrDefault<$Input,['output', 'errors', 'other'], 'default'>
schema: ConfigManager.GetAtPathOrDefault<$Input,['output', 'errors', 'schema'], false>
}
}
}
Expand Down
10 changes: 5 additions & 5 deletions src/layers/6_client/chainExtensions/anyware.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import { createExtension } from '../../../extension/extension.js'
import type { Anyware, Anyware as AnywareLib } from '../../../lib/anyware/__.js'
import { Chain } from '../../../lib/chain/__.js'
import { Builder } from '../../../lib/chain/__.js'
import type { RequestPipeline } from '../../../requestPipeline/__.js'
import { type Context } from '../context.js'

export interface Anyware_ extends Chain.Extension {
export interface Anyware_ extends Builder.Extension {
context: Context
// @ts-expect-error untyped params
return: Anyware<this['params']>
}

export interface Anyware<$Arguments extends Chain.Extension.Parameters<Anyware_>> {
export interface Anyware<$Arguments extends Builder.Extension.Parameters<Anyware_>> {
/**
* TODO Anyware Docs.
*/
anyware: (
anyware: AnywareLib.Extension2<RequestPipeline.Core<$Arguments['context']['config']>>,
) => Chain.Definition.MaterializeWithNewContext<$Arguments['chain'], $Arguments['context']>
) => Builder.Definition.MaterializeWithNewContext<$Arguments['chain'], $Arguments['context']>
}

export const AnywareExtension = Chain.Extension.create<Anyware_>((builder, context) => {
export const AnywareExtension = Builder.Extension.create<Anyware_>((builder, context) => {
const properties = {
anyware: (anyware: Anyware.Extension2<RequestPipeline.Core>) => {
return builder({
Expand Down
6 changes: 3 additions & 3 deletions src/layers/6_client/chainExtensions/internal.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { Chain } from '../../../lib/chain/__.js'
import type { Builder } from '../../../lib/chain/__.js'
import type { Context } from '../context.js'

export interface Internal_ extends Chain.Extension {
export interface Internal_ extends Builder.Extension {
context: Context
// @ts-expect-error untyped params
return: Internal<this['params']>
}

type Internal<$Args extends Chain.Extension.Parameters> = {
type Internal<$Args extends Builder.Extension.Parameters> = {
/**
* TODO
*/
Expand Down
Loading

0 comments on commit 27b4da7

Please sign in to comment.