Skip to content

Commit

Permalink
feat(ts-client): string support for custom scalars (#742)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonkuhrt authored Mar 30, 2024
1 parent 18d5c3f commit 34c9e25
Show file tree
Hide file tree
Showing 17 changed files with 255 additions and 154 deletions.
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
"types": "./build/entrypoints/alpha/schema.d.ts",
"default": "./build/entrypoints/alpha/schema.js"
}
},
"./alpha/schema/scalars": {
"import": {
"types": "./build/entrypoints/alpha/scalars.d.ts",
"default": "./build/entrypoints/alpha/scalars.js"
}
}
},
"packageManager": "pnpm@8.15.5",
Expand Down
5 changes: 4 additions & 1 deletion src/ResultSet/ResultSet.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/ban-types */

import { expectTypeOf, test } from 'vitest'
import type * as Schema from '../../tests/ts/_/schema.js'
import type * as Schema from '../../tests/ts/_/schema/Schema.js'
import type { SelectionSet } from '../SelectionSet/__.js'
import type { ResultSet } from './__.js'

Expand All @@ -22,6 +22,9 @@ test(`general`, () => {
expectTypeOf<RS<{ id: true; string: false }>>().toEqualTypeOf<{ id: null | string }>()
expectTypeOf<RS<{ id: true; string: 0 }>>().toEqualTypeOf<{ id: null | string }>()
expectTypeOf<RS<{ id: true; string: undefined }>>().toEqualTypeOf<{ id: null | string }>()

// Custom Scalar
expectTypeOf<RS<{ date: true }>>().toEqualTypeOf<{ date: null | string }>()

// List
expectTypeOf<RS<{ listIntNonNull: true }>>().toEqualTypeOf<{ listIntNonNull: number[] }>()
Expand Down
1 change: 0 additions & 1 deletion src/Schema/Index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,4 @@ export interface Index {
unions: {
Union: null | Union
}
scalars: object
}
17 changes: 16 additions & 1 deletion src/Schema/NamedType/Scalar/Scalar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { nativeScalarConstructors } from './nativeConstructors.js'

export { nativeScalarConstructors } from './nativeConstructors.js'

export const ScalarKind = `Scalar`

export type ScalarKind = typeof ScalarKind
Expand Down Expand Up @@ -44,4 +46,17 @@ export type Boolean = typeof Boolean

export type Float = typeof Float

export type Any = String | Int | Boolean | ID | Float
export const Scalars = {
String,
ID,
Int,
Float,
Boolean,
}

// eslint-disable-next-line
export type Any = String | Int | Boolean | ID | Float | SchemaCustomScalars[keyof SchemaCustomScalars]

declare global {
interface SchemaCustomScalars {}
}
9 changes: 8 additions & 1 deletion src/SelectionSet/SelectionSet.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { assertType, expectTypeOf, test } from 'vitest'
import type * as Schema from '../../tests/ts/_/schema.js'
import type * as Schema from '../../tests/ts/_/schema/Schema.js'
import type { SelectionSet } from './__.js'

type Q = SelectionSet.Query<Schema.$.Index>
Expand Down Expand Up @@ -29,6 +29,13 @@ test(`Query`, () => {
// non-null
assertType<Q>({ idNonNull: true })

// Custom Scalar
assertType<Q>({ date: true })
assertType<Q>({ date: false })
assertType<Q>({ date: 0 })
assertType<Q>({ date: 1 })
assertType<Q>({ date: undefined })

// Enum
assertType<Q>({ abcEnum: true })

Expand Down
2 changes: 1 addition & 1 deletion src/SelectionSet/toGraphQLDocumentString.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { parse, print } from 'graphql'
import { describe, expect, test } from 'vitest'
import type * as Schema from '../../tests/ts/_/schema.js'
import type * as Schema from '../../tests/ts/_/schema/Schema.js'
import type { SelectionSet } from './__.js'
import { toGraphQLDocumentString } from './toGraphQLDocumentString.js'

Expand Down
11 changes: 4 additions & 7 deletions src/cli/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const args = Command.create().description(`Generate a type safe GraphQL client.`
.parameter(`schema`, z.string().min(1).describe(`File path to where your GraphQL schema is.`))
.parameter(
`output`,
z.string().min(1).optional().describe(
`File path for where to output the generated TypeScript types. If not given, outputs to stdout.`,
z.string().min(1).describe(
`Directory path for where to output the generated TypeScript files.`,
),
)
.settings({
Expand All @@ -23,8 +23,5 @@ const args = Command.create().description(`Generate a type safe GraphQL client.`
const schemaSource = await fs.readFile(args.schema, `utf8`)
const code = generateCode({ schemaSource })

if (args.output) {
await fs.writeFile(args.output, code, { encoding: `utf8` })
} else {
console.log(code)
}
await fs.writeFile(`${args.output}/schema.ts`, code.schema, { encoding: `utf8` })
await fs.writeFile(`${args.output}/scalars.ts`, code.scalars, { encoding: `utf8` })
2 changes: 1 addition & 1 deletion src/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from 'vitest'
import { setupMockServer } from '../tests/raw/__helpers.js'
import type { $ } from '../tests/ts/_/schema.js'
import type { $ } from '../tests/ts/_/schema/Schema.js'
import { create } from './client.js'

const ctx = setupMockServer()
Expand Down
1 change: 1 addition & 0 deletions src/entrypoints/alpha/scalars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../../Schema/NamedType/Scalar/Scalar.js'
81 changes: 54 additions & 27 deletions src/generator/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ const referenceRenderers = defineReferenceRenderers({
GraphQLInterfaceType: (_, node) => Code.propertyAccess(namespaceNames.GraphQLInterfaceType, node.name),
GraphQLObjectType: (_, node) => Code.propertyAccess(namespaceNames.GraphQLObjectType, node.name),
GraphQLUnionType: (_, node) => Code.propertyAccess(namespaceNames.GraphQLUnionType, node.name),
GraphQLScalarType: (_, node) => `_.Scalar.${node.name}`,
GraphQLScalarType: (_, node) => `$Scalar.${node.name}`,
})

const dispatchToConcreteRenderer = (
Expand Down Expand Up @@ -312,18 +312,19 @@ const unwrapNonNull = (
return { node: nodeUnwrapped, nullable }
}

const scalarTypeMap: Record<string, 'string' | 'number' | 'boolean'> = {
ID: `string`,
Int: `number`,
String: `string`,
Float: `number`,
Boolean: `boolean`,
}
// const scalarTypeMap: Record<string, 'string' | 'number' | 'boolean'> = {
// ID: `string`,
// Int: `number`,
// String: `string`,
// Float: `number`,
// Boolean: `boolean`,
// }

// high level

interface Input {
schemaModulePath?: string
scalarsModulePath?: string
schemaSource: string
options?: {
TSDoc?: {
Expand Down Expand Up @@ -365,13 +366,16 @@ export const generateCode = (input: Input) => {
(_) => _.name === `Subscription`,
)

let code = ``
let schemaCode = ``

const schemaModulePath = input.schemaModulePath ?? `graphql-client/alpha/schema`
const scalarsModulePath = input.scalarsModulePath ?? `graphql-client/alpha/schema/scalars`

code += `import type * as _ from ${Code.quote(schemaModulePath)}\n\n`
schemaCode += `import type * as _ from ${Code.quote(schemaModulePath)}\n`
schemaCode += `import type * as $Scalar from './Scalar.ts'\n`
schemaCode += `\n\n`

code += Code.export$(
schemaCode += Code.export$(
Code.namespace(
`$`,
Code.group(
Expand Down Expand Up @@ -409,30 +413,21 @@ export const generateCode = (input: Input) => {
},
),
},
scalars: `Scalars`,
}),
),
),
Code.export$(
Code.interface$(
`Scalars`,
Code.objectFromEntries(typeMapByKind.GraphQLScalarType.map((_) => {
// todo strict mode where instead of falling back to "any" we throw an error
const type = scalarTypeMap[_.name] || `string`
return [_.name, type]
})),
),
),
),
),
)
// console.log(typeMapByKind.GraphQLScalarType)

for (const [name, types] of entries(typeMapByKind)) {
if (name === `GraphQLScalarType`) continue
if (name === `GraphQLCustomScalarType`) continue

const namespaceName = name === `GraphQLRootTypes` ? `Root` : namespaceNames[name]
code += Code.commentSectionTitle(namespaceName)
code += Code.export$(
schemaCode += Code.commentSectionTitle(namespaceName)
schemaCode += Code.export$(
Code.namespace(
namespaceName,
types.length === 0
Expand All @@ -444,16 +439,48 @@ export const generateCode = (input: Input) => {
)
}

return code
let scalarsCode = ``

scalarsCode += `import type * as Scalar from ${Code.quote(scalarsModulePath)}
declare global {
interface SchemaCustomScalars {
Date: Date
}
}
${
typeMapByKind.GraphQLCustomScalarType
.map((_) => {
return `
export const ${_.name} = Scalar.scalar('${_.name}', Scalar.nativeScalarConstructors.String)
export type ${_.name} = typeof ${_.name}
`
}).join(`\n`)
}
export * from ${Code.quote(scalarsModulePath)}
`

return {
scalars: scalarsCode,
schema: schemaCode,
}
}

export const generateFile = async (params: {
schemaPath: string
typeScriptPath: string
outputDirPath: string
schemaModulePath?: string
scalarsModulePath?: string
}) => {
// todo use @dprint/formatter
const schemaSource = await fs.readFile(params.schemaPath, `utf8`)
const code = generateCode({ schemaSource, ...params })
await fs.writeFile(params.typeScriptPath, code, { encoding: `utf8` })
await fs.mkdir(params.outputDirPath, { recursive: true })
await fs.writeFile(`${params.outputDirPath}/Schema.ts`, code.schema, { encoding: `utf8` })
await fs.writeFile(`${params.outputDirPath}/Scalar.ts`, code.scalars, { encoding: `utf8` })
}
13 changes: 13 additions & 0 deletions src/lib/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,23 @@ export type TypeMapByKind =
[Name in keyof NameToClassNamedType]: InstanceType<NameToClassNamedType[Name]>[]
}
& { GraphQLRootTypes: GraphQLObjectType<any, any>[] }
& { GraphQLCustomScalarType: GraphQLScalarType<any, any>[] }

const scalarTypeNames = {
String: `String`,
ID: `ID`,
Int: `Int`,
Float: `Float`,
Boolean: `Boolean`,
}

export const getTypeMapByKind = (schema: GraphQLSchema) => {
const typeMap = schema.getTypeMap()
const typeMapValues = Object.values(typeMap)
const typeMapByKind: TypeMapByKind = {
GraphQLRootTypes: [],
GraphQLScalarType: [],
GraphQLCustomScalarType: [],
GraphQLEnumType: [],
GraphQLInputObjectType: [],
GraphQLInterfaceType: [],
Expand All @@ -33,6 +43,9 @@ export const getTypeMapByKind = (schema: GraphQLSchema) => {
switch (true) {
case type instanceof GraphQLScalarType:
typeMapByKind.GraphQLScalarType.push(type)
if (!(type.name in scalarTypeNames)) {
typeMapByKind.GraphQLCustomScalarType.push(type)
}
break
case type instanceof GraphQLEnumType:
typeMapByKind.GraphQLEnumType.push(type)
Expand Down
14 changes: 9 additions & 5 deletions src/raw/lib/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const parseGraphQLExecutionResult = (result: unknown): Error | GraphQLReq
try {
if (Array.isArray(result)) {
return {
_tag: `Batch` as const,
_tag: `Batch`,
executionResults: result.map(parseExecutionResult),
}
} else if (isPlainObject(result)) {
Expand All @@ -53,7 +53,7 @@ export const parseGraphQLExecutionResult = (result: unknown): Error | GraphQLReq

/**
* Example result:
*
*
* ```
* {
* "data": null,
Expand All @@ -62,7 +62,7 @@ export const parseGraphQLExecutionResult = (result: unknown): Error | GraphQLReq
* "locations": [{ "line": 2, "column": 3 }],
* "path": ["playerNew"]
* }]
*}
* }
* ```
*/
export const parseExecutionResult = (result: unknown): GraphQLExecutionResultSingle => {
Expand All @@ -75,13 +75,17 @@ export const parseExecutionResult = (result: unknown): GraphQLExecutionResultSin
let extensions = undefined

if (`errors` in result) {
if (!isPlainObject(result.errors) && !Array.isArray(result.errors)) throw new Error(`Invalid execution result: errors is not plain object OR array`) // prettier-ignore
if (!isPlainObject(result.errors) && !Array.isArray(result.errors)) {
throw new Error(`Invalid execution result: errors is not plain object OR array`) // prettier-ignore
}
errors = result.errors
}

// todo add test coverage for case of null. @see https://github.com/jasonkuhrt/graphql-request/issues/739
if (`data` in result) {
if (!isPlainObject(result.data) && result.data !== null) throw new Error(`Invalid execution result: data is not plain object`) // prettier-ignore
if (!isPlainObject(result.data) && result.data !== null) {
throw new Error(`Invalid execution result: data is not plain object`) // prettier-ignore
}
data = result.data
}

Expand Down
3 changes: 3 additions & 0 deletions tests/ts/_/schema.graphql
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
scalar Date

type Query {
date: Date
interface: Interface
id: ID
idNonNull: ID!
Expand Down
12 changes: 12 additions & 0 deletions tests/ts/_/schema/Scalar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as Scalar from '../../../../src/Schema/NamedType/Scalar/Scalar.js'

declare global {
interface SchemaCustomScalars {
Date: Date
}
}

export const Date = Scalar.scalar(`Date`, Scalar.nativeScalarConstructors.String)
export type Date = typeof Date

export * from '../../../../src/Schema/NamedType/Scalar/Scalar.js'
Loading

0 comments on commit 34c9e25

Please sign in to comment.