Skip to content

Commit

Permalink
improve: introduce robust client preset utility (#1250)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonkuhrt authored Nov 3, 2024
1 parent e67dab0 commit e73241f
Show file tree
Hide file tree
Showing 46 changed files with 2,080 additions and 441 deletions.
131 changes: 131 additions & 0 deletions src/ClientPreset/ClientPreset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { CamelCase } from 'type-fest'
import type {
Extension,
ExtensionConstructor,
ExtensionInputParametersNone,
ExtensionInputParametersOptional,
ExtensionInputParametersRequired,
InferExtensionFromConstructor,
} from '../extension/extension.js'
import type { UseExtensionDo } from '../layers/6_client/builderExtensions/use.js'
import { type Client, createWithContext } from '../layers/6_client/client.js'
import { type Context, createContext, type TypeHooksEmpty } from '../layers/6_client/context.js'
import type { InputBase } from '../layers/6_client/Settings/Input.js'
import type { NormalizeInput } from '../layers/6_client/Settings/InputToConfig.js'
import type { Builder } from '../lib/builder/__.js'
import type { ConfigManager } from '../lib/config-manager/__.js'
import { type mergeArrayOfObjects, type ToParametersExact } from '../lib/prelude.js'
import type { GlobalRegistry } from '../types/GlobalRegistry/GlobalRegistry.js'
import { Schema } from '../types/Schema/__.js'
import type { SchemaDrivenDataMap } from '../types/SchemaDrivenDataMap/__.js'

/**
* Create a Client constructor with some initial context.
*
* Extensions constructors can be given. Their constructor parameters will
* be merged into the client constructor under a key matching the name of the extension.
*/
export const create: CreatePrefilled = (args) => {
const constructor = (input: any) => { // todo generic input type
const extensions = args.extensions?.map(extCtor => {
const extCtor_: (args: object | undefined) => Extension = extCtor
const keywordArgs: undefined | object = input?.[extCtor.info.name]
return extCtor_(keywordArgs)
}) ?? []

const scalars = args.scalars ?? Schema.Scalar.Registry.empty
const schemaMap = args.sddm ?? null

const initialState = createContext({
name: args.name,
extensions,
scalars,
schemaMap,
input: {
schema: args.schemaUrl,
// eslint-disable-next-line
// @ts-ignore passes after generation
...input,
name: args.name,
},
// retry: null,
})

const instance = createWithContext(initialState)

return instance
}

return constructor as any
}

// dprint-ignore
type CreatePrefilled = <
const $Name extends string,
$Scalars extends Schema.Scalar.Registry,
const $ExtensionConstructors extends [...ExtensionConstructor<any>[]],
$Params extends {
name: $Name
sddm?: SchemaDrivenDataMap
scalars?: $Scalars
schemaUrl?: URL | undefined
extensions?: $ExtensionConstructors
},
>(keywordArgs: $Params) =>
{
preset: $Params
<$ClientKeywordArgs extends ConstructorParameters<$Name, ConfigManager.OrDefault<$Params['extensions'], []>>>(
...args: ToParametersExact<
$ClientKeywordArgs,
ConstructorParameters<$Name, ConfigManager.OrDefault<$Params['extensions'], []>>
>
): ApplyPrefilledExtensions<
ConfigManager.OrDefault<$Params['extensions'], []>,
// @ts-expect-error fixme
Client<{
input: $ClientKeywordArgs
name: $Params['name']
schemaMap: ConfigManager.OrDefault<$Params['sddm'], null>
scalars: ConfigManager.OrDefault<$Params['scalars'], Schema.Scalar.Registry.Empty>
config: NormalizeInput<$ClientKeywordArgs & { name: $Name; schemaMap: SchemaDrivenDataMap }>
typeHooks: TypeHooksEmpty
// This will be populated by statically applying preset extensions.
extensions: []
// retry: null
}>
>
}

type ConstructorParameters<
$Name extends string,
$Extensions extends [...ExtensionConstructor[]],
> =
& InputBase<GlobalRegistry.GetOrGeneric<$Name>>
& mergeArrayOfObjects<GetParametersContributedByExtensions<$Extensions>>

// dprint-ignore
type GetParametersContributedByExtensions<Extensions extends [...ExtensionConstructor[]]> = {
[$Index in keyof Extensions]:
Extensions[$Index]['info']['configInputParameters'] extends ExtensionInputParametersNone ? {} :
Extensions[$Index]['info']['configInputParameters'] extends ExtensionInputParametersRequired ? { [_ in CamelCase<Extensions[$Index]['info']['name']>]: Extensions[$Index]['info']['configInputParameters'][0] } :
Extensions[$Index]['info']['configInputParameters'] extends ExtensionInputParametersOptional ? { [_ in CamelCase<Extensions[$Index]['info']['name']>]?: Extensions[$Index]['info']['configInputParameters'][0] } :
{}
}

// dprint-ignore
type ApplyPrefilledExtensions<
$ExtensionConstructors extends [...ExtensionConstructor[]],
$Client extends Client<Context>,
> =
$ExtensionConstructors extends []
? $Client
: $ExtensionConstructors extends [infer $ExtensionConstructor extends ExtensionConstructor, ...infer $Rest extends ExtensionConstructor[]]
? ApplyPrefilledExtensions<
$Rest,
// @ts-expect-error fixme
UseExtensionDo<
Builder.Private.Get<$Client>,
InferExtensionFromConstructor<$ExtensionConstructor>
>
>
: never
50 changes: 50 additions & 0 deletions src/ClientPreset/__.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { IntrospectionQuery } from 'graphql'
import { Introspection } from '../extensions/Introspection/Introspection.js'
import { create } from '../layers/6_client/client.js'
import { assertEqual, assertExtends } from '../lib/assert-equal.js'
import { ClientPreset } from './__.js'

// Baseline tests of the base client constructor.
// These are here for easy comparison to the preset client.
{
const graffle = create({
name: `test`,
// @ts-expect-error not available
introspection: {
options: {
descriptions: true,
},
},
})
assertEqual<typeof graffle._.name, string>()
}

// Preset Without Extensions.
{
const create = ClientPreset.create({ name: `test` })
assertEqual<typeof create.preset, { name: 'test' }>()
const graffle = create()
assertEqual<typeof graffle._.name, 'test'>()
assertEqual<typeof graffle._.typeHooks.onRequestResult, []>()
}

// Preset With Extensions.
{
const create = ClientPreset.create({
name: `test`,
extensions: [Introspection],
})
assertEqual<typeof create.preset, { name: 'test'; extensions: [any] }>()
const graffle = create({
// Extension config is available here
introspection: {
options: {
descriptions: true,
},
},
})
assertEqual<typeof graffle._.typeHooks.onRequestResult, []>()
assertExtends<typeof graffle._, { name: 'test'; extensions: [{ name: 'Introspection' }] }>()
const result = await graffle.introspect()
assertEqual<typeof result, IntrospectionQuery | null>()
}
44 changes: 44 additions & 0 deletions src/ClientPreset/__.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { expect, test } from 'vitest'
import { createExtension } from '../entrypoints/extensionkit.js'
import { Introspection } from '../extensions/Introspection/Introspection.js'
import { ClientPreset } from './__.js'

test(`Preset extension is used on constructed client`, () => {
const create = ClientPreset.create({ name: `test`, extensions: [Introspection] })
const graffle = create({ introspection: { options: { descriptions: true } } })
expect(typeof graffle.introspect).toBe(`function`)
})

test(`If extension required input then client constructor input property is NOT optional`, () => {
const Ex = createExtension({
name: `test`,
normalizeConfig: (_: { a: 1; b?: 2 }) => {
return { a: 11, b: 22 }
},
create: () => {
return {}
},
})
const create = ClientPreset.create({ name: `test`, extensions: [Ex] })
// @ts-expect-error Arguments required.
create()
// @ts-expect-error Arguments required.
create({})
create({ test: { a: 1 } })
})

test(`If extension has no required input then client constructor input property IS optional`, () => {
const Ex = createExtension({
name: `test`,
normalizeConfig: (_?: { a?: 1; b?: 2 }) => {
return { a: 11, b: 22 }
},
create: () => {
return {}
},
})
const create = ClientPreset.create({ name: `test`, extensions: [Ex] })
// OK. Arguments NOT required.
create()
create({})
})
1 change: 1 addition & 0 deletions src/ClientPreset/__.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as ClientPreset from './ClientPreset.js'
2 changes: 1 addition & 1 deletion src/entrypoints/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { ClientPreset } from '../ClientPreset/__.js'
export { create as createSelect, select } from '../layers/5_select/select.js'
export { type Client, create } from '../layers/6_client/client.js'
export { createPrefilled } from '../layers/6_client/clientPrefilled.js'
export { type InputStatic } from '../layers/6_client/Settings/Input.js'
79 changes: 79 additions & 0 deletions src/extension/extension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expectTypeOf, test } from 'vitest'
import { createExtension } from './extension.js'

describe(`constructor arguments`, () => {
test(`normalizeConfig undefined -> constructor input forbidden`, () => {
const Ex = createExtension({
name: `test`,
create: () => {
return {}
},
})
Ex()
// @ts-expect-error Arguments forbidden.
Ex({})
})
test(`normalizeConfig with optional keys -> constructor input optional`, () => {
const Ex = createExtension({
name: `test`,
normalizeConfig: (_?: { a?: 1; b?: 2 }) => {
return { a: 11, b: 22 }
},
create: () => {
return {}
},
})
Ex()
Ex({})
Ex({ a: 1 })
})
test(`normalizeConfig with required input (but optional keys) -> constructor input required`, () => {
const Ex = createExtension({
name: `test`,
normalizeConfig: (_: { a?: 1; b?: 2 }) => {
return { a: 11, b: 22 }
},
create: () => {
return {}
},
})
// @ts-expect-error Arguments required.
Ex()
Ex({})
Ex({ a: 1 })
})
test(`normalizeConfig with required keys -> constructor input required`, () => {
const Ex = createExtension({
name: `test`,
normalizeConfig: (_: { a: 1; b?: 2 }) => {
return { a: 11, b: 22 }
},
create: () => {
return {}
},
})
// @ts-expect-error Arguments required.
Ex()
// @ts-expect-error Arguments required.
Ex({})
Ex({ a: 1 })
})
})

test(`type hooks is empty by default`, () => {
const Ex = createExtension({
name: `test`,
create: () => {
return {}
},
})
expectTypeOf(Ex.info.typeHooks).toEqualTypeOf<{
onRequestResult: undefined
onRequestDocumentRootType: undefined
}>()
const ex = Ex()
expectTypeOf(ex.typeHooks).toEqualTypeOf<{
onRequestResult: undefined
onRequestDocumentRootType: undefined
}>()
})
Loading

0 comments on commit e73241f

Please sign in to comment.