diff --git a/src/Errors.ts b/src/Errors.ts index 7db673c6..fe1c3f69 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -115,6 +115,10 @@ export function functionFieldNotNamedExport() { return `Expected a \`@${FIELD_TAG}\` function to be a named export. Grats needs to import resolver functions into it's generated schema module, so the resolver function must be a named export.`; } +export function customScalarTypeNotExported() { + return `Expected a \`@${SCALAR_TAG}\` type to be a named export. Grats needs to import custom scalar types into it's generated schema module, so the type must be a named export.`; +} + export function inputTypeNotLiteral() { return `\`@${INPUT_TAG}\` can only be used on type literals. e.g. \`type MyInput = { foo: string }\``; } diff --git a/src/Extractor.ts b/src/Extractor.ts index 3cf1e56f..177f6a4c 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -435,6 +435,16 @@ class Extractor { return node.name; } + typeAliasExportName(node: ts.TypeAliasDeclaration): ts.Identifier | null { + const exportKeyword = node.modifiers?.some((modifier) => { + return modifier.kind === ts.SyntaxKind.ExportKeyword; + }); + if (exportKeyword == null) { + return this.report(node.name, E.customScalarTypeNotExported()); + } + return node.name; + } + scalarTypeAliasDeclaration(node: ts.TypeAliasDeclaration, tag: ts.JSDocTag) { const name = this.entityName(node, tag); if (name == null) return null; @@ -442,8 +452,22 @@ class Extractor { const description = this.collectDescription(node); this.recordTypeName(node.name, name, "SCALAR"); + // Ensure the type is exported + const exportName = this.typeAliasExportName(node); + if (exportName == null) return null; + + const tsModulePath = relativePath(node.getSourceFile().fileName); + + const directives = [ + this.gql.exportedDirective(exportName, { + tsModulePath, + exportedFunctionName: exportName.text, + argCount: 0, + }), + ]; + this.definitions.push( - this.gql.scalarTypeDefinition(node, name, description), + this.gql.scalarTypeDefinition(node, name, directives, description), ); } diff --git a/src/GraphQLConstructor.ts b/src/GraphQLConstructor.ts index 33455416..beff67d3 100644 --- a/src/GraphQLConstructor.ts +++ b/src/GraphQLConstructor.ts @@ -228,6 +228,7 @@ export class GraphQLConstructor { scalarTypeDefinition( node: ts.Node, name: NameNode, + directives: readonly ConstDirectiveNode[] | null, description: StringValueNode | null, ): ScalarTypeDefinitionNode { return { @@ -235,7 +236,7 @@ export class GraphQLConstructor { loc: this._loc(node), description: description ?? undefined, name, - directives: undefined, + directives: this._optionalList(directives), }; } diff --git a/src/codegen.ts b/src/codegen.ts index 701b40e2..464aa62f 100644 --- a/src/codegen.ts +++ b/src/codegen.ts @@ -35,6 +35,19 @@ import { } from "./metadataDirectives"; import { resolveRelativePath } from "./gratsRoot"; +const SCHEMA_CONFIG_TYPE_NAME = "SchemaConfigType"; +const SCHEMA_CONFIG_NAME = "config"; +const SCHEMA_CONFIG_SCALARS_NAME = "scalars"; + +const SCALAR_CONFIG_TYPE_NAME = "ScalarConfigType"; +const PRIMITIVE_TYPE_NAMES = new Set([ + "String", + "Int", + "Float", + "Boolean", + "ID", +]); + const F = ts.factory; // Given a GraphQL SDL, returns the a string of TypeScript code that generates a @@ -74,15 +87,33 @@ class Codegen { return F.createIdentifier(name); } - graphQLTypeImport(name: string): ts.TypeReferenceNode { + graphQLTypeImport( + name: string, + typeArguments?: readonly ts.TypeNode[], + ): ts.TypeReferenceNode { this._graphQLImports.add(name); - return F.createTypeReferenceNode(name); + return F.createTypeReferenceNode(name, typeArguments); } schemaDeclarationExport(): void { + const schemaConfigType = this.schemaConfigTypeDeclaration(); + const params: ts.ParameterDeclaration[] = []; + if (schemaConfigType != null) { + this._statements.push(schemaConfigType); + params.push( + F.createParameterDeclaration( + undefined, + undefined, + SCHEMA_CONFIG_NAME, + undefined, + F.createTypeReferenceNode(SCHEMA_CONFIG_TYPE_NAME), + ), + ); + } this.functionDeclaration( "getSchema", [F.createModifier(ts.SyntaxKind.ExportKeyword)], + params, this.graphQLTypeImport("GraphQLSchema"), this.createBlockWithScope(() => { this._statements.push( @@ -90,7 +121,7 @@ class Codegen { F.createNewExpression( this.graphQLImport("GraphQLSchema"), [], - [this.schemaConfig()], + [this.schemaConfigObject()], ), ), ); @@ -98,7 +129,98 @@ class Codegen { ); } - schemaConfig(): ts.ObjectLiteralExpression { + schemaConfigTypeDeclaration(): ts.TypeAliasDeclaration | null { + const configType = this.schemaConfigType(); + if (configType == null) return null; + return F.createTypeAliasDeclaration( + [F.createModifier(ts.SyntaxKind.ExportKeyword)], + SCHEMA_CONFIG_TYPE_NAME, + undefined, + configType, + ); + } + + schemaConfigType(): ts.TypeLiteralNode | null { + const scalarType = this.schemaConfigScalarType(); + if (scalarType == null) return null; + return F.createTypeLiteralNode([scalarType]); + } + + schemaConfigScalarType(): ts.TypeElement | null { + const typeMap = this._schema.getTypeMap(); + const scalarTypes = Object.values(typeMap) + .filter(isScalarType) + .filter((scalar) => { + // Built in primitives + return !PRIMITIVE_TYPE_NAMES.has(scalar.name); + }); + if (scalarTypes.length == 0) return null; + this._statements.push(this.scalarConfigTypeDeclaration()); + return F.createPropertySignature( + undefined, + SCHEMA_CONFIG_SCALARS_NAME, + undefined, + F.createTypeLiteralNode( + scalarTypes.map((scalar) => { + return F.createPropertySignature( + undefined, + scalar.name, + undefined, + F.createTypeReferenceNode(SCALAR_CONFIG_TYPE_NAME, [ + F.createTypeReferenceNode( + formatCustomScalarTypeName(scalar.name), + ), + ]), + ); + }), + ), + ); + } + + scalarConfigTypeDeclaration(): ts.TypeAliasDeclaration { + return F.createTypeAliasDeclaration( + undefined, + SCALAR_CONFIG_TYPE_NAME, + [F.createTypeParameterDeclaration(undefined, "T")], + F.createTypeLiteralNode([ + F.createMethodSignature( + undefined, + "serialize", + undefined, + undefined, + [ + F.createParameterDeclaration( + undefined, + undefined, + "outputValue", + undefined, + F.createTypeReferenceNode("T"), + ), + ], + F.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), + ), + F.createPropertySignature( + undefined, + "parseValue", + undefined, + + this.graphQLTypeImport("GraphQLScalarValueParser", [ + F.createTypeReferenceNode("T"), + ]), + ), + F.createPropertySignature( + undefined, + "parseLiteral", + undefined, + this.graphQLTypeImport("GraphQLScalarLiteralParser", [ + F.createTypeReferenceNode("T"), + ]), + ), + ]), + ); + } + + schemaConfigObject(): ts.ObjectLiteralExpression { return this.objectLiteral([ this.description(this._schema.description), this.query(), @@ -115,12 +237,7 @@ class Codegen { type.name.startsWith("__") || type.name.startsWith("Introspection") || type.name.startsWith("Schema") || - // Built in primitives - type.name === "String" || - type.name === "Int" || - type.name === "Float" || - type.name === "Boolean" || - type.name === "ID" + PRIMITIVE_TYPE_NAMES.has(type.name) ); }) .map((type) => this.typeReference(type)); @@ -393,7 +510,7 @@ class Codegen { varName, F.createNewExpression( this.graphQLImport("GraphQLScalarType"), - [], + [F.createTypeReferenceNode(formatCustomScalarTypeName(obj.name))], [this.customScalarTypeConfig(obj)], ), // We need to explicitly specify the type due to circular references in @@ -405,9 +522,43 @@ class Codegen { } customScalarTypeConfig(obj: GraphQLScalarType): ts.ObjectLiteralExpression { + const exported = fieldDirective(obj, EXPORTED_DIRECTIVE); + if (exported != null) { + const exportedMetadata = parseExportedDirective(exported); + const module = exportedMetadata.tsModulePath; + const funcName = exportedMetadata.exportedFunctionName; + const abs = resolveRelativePath(module); + const relative = stripExt( + path.relative(path.dirname(this._destination), abs), + ); + + const scalarTypeName = formatCustomScalarTypeName(obj.name); + this.import(`./${relative}`, [{ name: funcName, as: scalarTypeName }]); + } return this.objectLiteral([ this.description(obj.description), F.createPropertyAssignment("name", F.createStringLiteral(obj.name)), + ...["serialize", "parseValue", "parseLiteral"].map((name) => { + let func: ts.Expression = F.createPropertyAccessExpression( + F.createPropertyAccessExpression( + F.createPropertyAccessExpression( + F.createIdentifier(SCHEMA_CONFIG_NAME), + SCHEMA_CONFIG_SCALARS_NAME, + ), + obj.name, + ), + name, + ); + if (name === "serialize") { + func = F.createAsExpression( + func, + this.graphQLTypeImport("GraphQLScalarSerializer", [ + F.createTypeReferenceNode(formatCustomScalarTypeName(obj.name)), + ]), + ); + } + return F.createPropertyAssignment(name, func); + }), ]); } @@ -663,6 +814,7 @@ class Codegen { functionDeclaration( name: string, modifiers: ts.Modifier[] | undefined, + parameters: ts.ParameterDeclaration[], type: ts.TypeNode | undefined, body: ts.Block, ): void { @@ -672,7 +824,7 @@ class Codegen { undefined, name, undefined, - [], + parameters, type, body, ), @@ -769,7 +921,7 @@ class Codegen { } function fieldDirective( - field: GraphQLField, + field: GraphQLField | GraphQLScalarType, name: string, ): ConstDirectiveNode | null { return field.astNode?.directives?.find((d) => d.name.value === name) ?? null; @@ -794,3 +946,7 @@ function formatResolverFunctionVarName( const field = fieldName[0].toUpperCase() + fieldName.slice(1); return `${parent}${field}Resolver`; } + +function formatCustomScalarTypeName(scalarName: string): string { + return `${scalarName}Type`; +} diff --git a/src/metadataDirectives.ts b/src/metadataDirectives.ts index 4e418c3c..403abb94 100644 --- a/src/metadataDirectives.ts +++ b/src/metadataDirectives.ts @@ -34,7 +34,7 @@ export const DIRECTIVES_AST: DocumentNode = parse(` ${TS_MODULE_PATH_ARG}: String!, ${EXPORTED_FUNCTION_NAME_ARG}: String! ${ARG_COUNT}: Int! - ) on FIELD_DEFINITION + ) on FIELD_DEFINITION | SCALAR directive @${KILLS_PARENT_ON_EXCEPTION_DIRECTIVE} on FIELD_DEFINITION `); diff --git a/src/tests/fixtures/arguments/CustomScalarArgument.ts b/src/tests/fixtures/arguments/CustomScalarArgument.ts index b4fad38f..089e376a 100644 --- a/src/tests/fixtures/arguments/CustomScalarArgument.ts +++ b/src/tests/fixtures/arguments/CustomScalarArgument.ts @@ -1,5 +1,5 @@ /** @gqlScalar */ -type MyString = string; +export type MyString = string; /** @gqlType */ export default class SomeType { diff --git a/src/tests/fixtures/arguments/CustomScalarArgument.ts.expected b/src/tests/fixtures/arguments/CustomScalarArgument.ts.expected index c3fac90f..0b8c8f66 100644 --- a/src/tests/fixtures/arguments/CustomScalarArgument.ts.expected +++ b/src/tests/fixtures/arguments/CustomScalarArgument.ts.expected @@ -2,7 +2,7 @@ INPUT ----------------- /** @gqlScalar */ -type MyString = string; +export type MyString = string; /** @gqlType */ export default class SomeType { @@ -16,16 +16,30 @@ export default class SomeType { OUTPUT ----------------- -- SDL -- -scalar MyString +scalar MyString @exported(tsModulePath: "grats/src/tests/fixtures/arguments/CustomScalarArgument.ts", functionName: "MyString", argCount: 0) type SomeType { hello(greeting: MyString!): String } -- TypeScript -- -import { GraphQLSchema, GraphQLScalarType, GraphQLObjectType, GraphQLString, GraphQLNonNull } from "graphql"; -export function getSchema(): GraphQLSchema { - const MyStringType: GraphQLScalarType = new GraphQLScalarType({ - name: "MyString" +import { MyString as MyStringType } from "./CustomScalarArgument"; +import { GraphQLScalarValueParser, GraphQLScalarLiteralParser, GraphQLSchema, GraphQLScalarType, GraphQLScalarSerializer, GraphQLObjectType, GraphQLString, GraphQLNonNull } from "graphql"; +type ScalarConfigType = { + serialize(outputValue: T): any; + parseValue: GraphQLScalarValueParser; + parseLiteral: GraphQLScalarLiteralParser; +}; +export type SchemaConfigType = { + scalars: { + MyString: ScalarConfigType; + }; +}; +export function getSchema(config: SchemaConfigType): GraphQLSchema { + const MyStringType: GraphQLScalarType = new GraphQLScalarType({ + name: "MyString", + serialize: config.scalars.MyString.serialize as GraphQLScalarSerializer, + parseValue: config.scalars.MyString.parseValue, + parseLiteral: config.scalars.MyString.parseLiteral }); const SomeTypeType: GraphQLObjectType = new GraphQLObjectType({ name: "SomeType", diff --git a/src/tests/fixtures/custom_scalars/DefineCustomScalar.ts b/src/tests/fixtures/custom_scalars/DefineCustomScalar.ts index 8280d7b5..4d269944 100644 --- a/src/tests/fixtures/custom_scalars/DefineCustomScalar.ts +++ b/src/tests/fixtures/custom_scalars/DefineCustomScalar.ts @@ -5,4 +5,4 @@ class SomeType { } /** @gqlScalar */ -type MyUrl = string; +export type MyUrl = string; diff --git a/src/tests/fixtures/custom_scalars/DefineCustomScalar.ts.expected b/src/tests/fixtures/custom_scalars/DefineCustomScalar.ts.expected index 64098700..109fcf70 100644 --- a/src/tests/fixtures/custom_scalars/DefineCustomScalar.ts.expected +++ b/src/tests/fixtures/custom_scalars/DefineCustomScalar.ts.expected @@ -8,7 +8,7 @@ class SomeType { } /** @gqlScalar */ -type MyUrl = string; +export type MyUrl = string; ----------------- OUTPUT @@ -18,10 +18,21 @@ type SomeType { hello: String } -scalar MyUrl +scalar MyUrl @exported(tsModulePath: "grats/src/tests/fixtures/custom_scalars/DefineCustomScalar.ts", functionName: "MyUrl", argCount: 0) -- TypeScript -- -import { GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLScalarType } from "graphql"; -export function getSchema(): GraphQLSchema { +import { MyUrl as MyUrlType } from "./DefineCustomScalar"; +import { GraphQLScalarValueParser, GraphQLScalarLiteralParser, GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLScalarType, GraphQLScalarSerializer } from "graphql"; +type ScalarConfigType = { + serialize(outputValue: T): any; + parseValue: GraphQLScalarValueParser; + parseLiteral: GraphQLScalarLiteralParser; +}; +export type SchemaConfigType = { + scalars: { + MyUrl: ScalarConfigType; + }; +}; +export function getSchema(config: SchemaConfigType): GraphQLSchema { const SomeTypeType: GraphQLObjectType = new GraphQLObjectType({ name: "SomeType", fields() { @@ -33,8 +44,11 @@ export function getSchema(): GraphQLSchema { }; } }); - const MyUrlType: GraphQLScalarType = new GraphQLScalarType({ - name: "MyUrl" + const MyUrlType: GraphQLScalarType = new GraphQLScalarType({ + name: "MyUrl", + serialize: config.scalars.MyUrl.serialize as GraphQLScalarSerializer, + parseValue: config.scalars.MyUrl.parseValue, + parseLiteral: config.scalars.MyUrl.parseLiteral }); return new GraphQLSchema({ types: [SomeTypeType, MyUrlType] diff --git a/src/tests/fixtures/custom_scalars/DefineCustomScalarWithDescription.ts b/src/tests/fixtures/custom_scalars/DefineCustomScalarWithDescription.ts index 029b44ca..e0e0de2a 100644 --- a/src/tests/fixtures/custom_scalars/DefineCustomScalarWithDescription.ts +++ b/src/tests/fixtures/custom_scalars/DefineCustomScalarWithDescription.ts @@ -8,4 +8,4 @@ class SomeType { * Use this for URLs. * @gqlScalar */ -type MyUrl = string; +export type MyUrl = string; diff --git a/src/tests/fixtures/custom_scalars/DefineCustomScalarWithDescription.ts.expected b/src/tests/fixtures/custom_scalars/DefineCustomScalarWithDescription.ts.expected index c8833f76..998ecdd2 100644 --- a/src/tests/fixtures/custom_scalars/DefineCustomScalarWithDescription.ts.expected +++ b/src/tests/fixtures/custom_scalars/DefineCustomScalarWithDescription.ts.expected @@ -11,7 +11,7 @@ class SomeType { * Use this for URLs. * @gqlScalar */ -type MyUrl = string; +export type MyUrl = string; ----------------- OUTPUT @@ -22,10 +22,21 @@ type SomeType { } """Use this for URLs.""" -scalar MyUrl +scalar MyUrl @exported(tsModulePath: "grats/src/tests/fixtures/custom_scalars/DefineCustomScalarWithDescription.ts", functionName: "MyUrl", argCount: 0) -- TypeScript -- -import { GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLScalarType } from "graphql"; -export function getSchema(): GraphQLSchema { +import { MyUrl as MyUrlType } from "./DefineCustomScalarWithDescription"; +import { GraphQLScalarValueParser, GraphQLScalarLiteralParser, GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLScalarType, GraphQLScalarSerializer } from "graphql"; +type ScalarConfigType = { + serialize(outputValue: T): any; + parseValue: GraphQLScalarValueParser; + parseLiteral: GraphQLScalarLiteralParser; +}; +export type SchemaConfigType = { + scalars: { + MyUrl: ScalarConfigType; + }; +}; +export function getSchema(config: SchemaConfigType): GraphQLSchema { const SomeTypeType: GraphQLObjectType = new GraphQLObjectType({ name: "SomeType", fields() { @@ -37,9 +48,12 @@ export function getSchema(): GraphQLSchema { }; } }); - const MyUrlType: GraphQLScalarType = new GraphQLScalarType({ + const MyUrlType: GraphQLScalarType = new GraphQLScalarType({ description: "Use this for URLs.", - name: "MyUrl" + name: "MyUrl", + serialize: config.scalars.MyUrl.serialize as GraphQLScalarSerializer, + parseValue: config.scalars.MyUrl.parseValue, + parseLiteral: config.scalars.MyUrl.parseLiteral }); return new GraphQLSchema({ types: [SomeTypeType, MyUrlType] diff --git a/src/tests/fixtures/custom_scalars/DefineRenamedCustomScalar.ts b/src/tests/fixtures/custom_scalars/DefineRenamedCustomScalar.ts index a9da6105..e8de048b 100644 --- a/src/tests/fixtures/custom_scalars/DefineRenamedCustomScalar.ts +++ b/src/tests/fixtures/custom_scalars/DefineRenamedCustomScalar.ts @@ -5,4 +5,4 @@ class SomeType { } /** @gqlScalar CustomName */ -type MyUrl = string; +export type MyUrl = string; diff --git a/src/tests/fixtures/custom_scalars/DefineRenamedCustomScalar.ts.expected b/src/tests/fixtures/custom_scalars/DefineRenamedCustomScalar.ts.expected index 9d27c358..40f15ff1 100644 --- a/src/tests/fixtures/custom_scalars/DefineRenamedCustomScalar.ts.expected +++ b/src/tests/fixtures/custom_scalars/DefineRenamedCustomScalar.ts.expected @@ -8,7 +8,7 @@ class SomeType { } /** @gqlScalar CustomName */ -type MyUrl = string; +export type MyUrl = string; ----------------- OUTPUT @@ -18,10 +18,21 @@ type SomeType { hello: String } -scalar CustomName +scalar CustomName @exported(tsModulePath: "grats/src/tests/fixtures/custom_scalars/DefineRenamedCustomScalar.ts", functionName: "MyUrl", argCount: 0) -- TypeScript -- -import { GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLScalarType } from "graphql"; -export function getSchema(): GraphQLSchema { +import { MyUrl as CustomNameType } from "./DefineRenamedCustomScalar"; +import { GraphQLScalarValueParser, GraphQLScalarLiteralParser, GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLScalarType, GraphQLScalarSerializer } from "graphql"; +type ScalarConfigType = { + serialize(outputValue: T): any; + parseValue: GraphQLScalarValueParser; + parseLiteral: GraphQLScalarLiteralParser; +}; +export type SchemaConfigType = { + scalars: { + CustomName: ScalarConfigType; + }; +}; +export function getSchema(config: SchemaConfigType): GraphQLSchema { const SomeTypeType: GraphQLObjectType = new GraphQLObjectType({ name: "SomeType", fields() { @@ -33,8 +44,11 @@ export function getSchema(): GraphQLSchema { }; } }); - const CustomNameType: GraphQLScalarType = new GraphQLScalarType({ - name: "CustomName" + const CustomNameType: GraphQLScalarType = new GraphQLScalarType({ + name: "CustomName", + serialize: config.scalars.CustomName.serialize as GraphQLScalarSerializer, + parseValue: config.scalars.CustomName.parseValue, + parseLiteral: config.scalars.CustomName.parseLiteral }); return new GraphQLSchema({ types: [SomeTypeType, CustomNameType] diff --git a/src/tests/fixtures/field_values/CustomScalar.ts b/src/tests/fixtures/field_values/CustomScalar.ts index e2b001eb..9bc10f33 100644 --- a/src/tests/fixtures/field_values/CustomScalar.ts +++ b/src/tests/fixtures/field_values/CustomScalar.ts @@ -1,5 +1,5 @@ /** @gqlScalar */ -type MyString = string; +export type MyString = string; /** @gqlType */ export default class SomeType { diff --git a/src/tests/fixtures/field_values/CustomScalar.ts.expected b/src/tests/fixtures/field_values/CustomScalar.ts.expected index ffa23dab..87c8e25b 100644 --- a/src/tests/fixtures/field_values/CustomScalar.ts.expected +++ b/src/tests/fixtures/field_values/CustomScalar.ts.expected @@ -2,7 +2,7 @@ INPUT ----------------- /** @gqlScalar */ -type MyString = string; +export type MyString = string; /** @gqlType */ export default class SomeType { @@ -16,16 +16,30 @@ export default class SomeType { OUTPUT ----------------- -- SDL -- -scalar MyString +scalar MyString @exported(tsModulePath: "grats/src/tests/fixtures/field_values/CustomScalar.ts", functionName: "MyString", argCount: 0) type SomeType { hello: MyString } -- TypeScript -- -import { GraphQLSchema, GraphQLScalarType, GraphQLObjectType } from "graphql"; -export function getSchema(): GraphQLSchema { - const MyStringType: GraphQLScalarType = new GraphQLScalarType({ - name: "MyString" +import { MyString as MyStringType } from "./CustomScalar"; +import { GraphQLScalarValueParser, GraphQLScalarLiteralParser, GraphQLSchema, GraphQLScalarType, GraphQLScalarSerializer, GraphQLObjectType } from "graphql"; +type ScalarConfigType = { + serialize(outputValue: T): any; + parseValue: GraphQLScalarValueParser; + parseLiteral: GraphQLScalarLiteralParser; +}; +export type SchemaConfigType = { + scalars: { + MyString: ScalarConfigType; + }; +}; +export function getSchema(config: SchemaConfigType): GraphQLSchema { + const MyStringType: GraphQLScalarType = new GraphQLScalarType({ + name: "MyString", + serialize: config.scalars.MyString.serialize as GraphQLScalarSerializer, + parseValue: config.scalars.MyString.parseValue, + parseLiteral: config.scalars.MyString.parseLiteral }); const SomeTypeType: GraphQLObjectType = new GraphQLObjectType({ name: "SomeType", diff --git a/src/tests/fixtures/locate/fieldOnScalar.invalid.ts b/src/tests/fixtures/locate/fieldOnScalar.invalid.ts index f4e509de..9f18a922 100644 --- a/src/tests/fixtures/locate/fieldOnScalar.invalid.ts +++ b/src/tests/fixtures/locate/fieldOnScalar.invalid.ts @@ -1,3 +1,3 @@ // Locate: Date.name /** @gqlScalar */ -type Date = string; +export type Date = string; diff --git a/src/tests/fixtures/locate/fieldOnScalar.invalid.ts.expected b/src/tests/fixtures/locate/fieldOnScalar.invalid.ts.expected index 52515ed4..d8472fa0 100644 --- a/src/tests/fixtures/locate/fieldOnScalar.invalid.ts.expected +++ b/src/tests/fixtures/locate/fieldOnScalar.invalid.ts.expected @@ -3,7 +3,7 @@ INPUT ----------------- // Locate: Date.name /** @gqlScalar */ -type Date = string; +export type Date = string; ----------------- OUTPUT diff --git a/src/tests/fixtures/todo/RedefineBuiltinScalar.invalid.ts b/src/tests/fixtures/todo/RedefineBuiltinScalar.invalid.ts index c3269f96..bea11195 100644 --- a/src/tests/fixtures/todo/RedefineBuiltinScalar.invalid.ts +++ b/src/tests/fixtures/todo/RedefineBuiltinScalar.invalid.ts @@ -1,2 +1,2 @@ /** @gqlScalar String */ -type MyUrl = string; +export type MyUrl = string; diff --git a/src/tests/fixtures/todo/RedefineBuiltinScalar.invalid.ts.expected b/src/tests/fixtures/todo/RedefineBuiltinScalar.invalid.ts.expected index 230e1881..3f410dac 100644 --- a/src/tests/fixtures/todo/RedefineBuiltinScalar.invalid.ts.expected +++ b/src/tests/fixtures/todo/RedefineBuiltinScalar.invalid.ts.expected @@ -2,7 +2,7 @@ INPUT ----------------- /** @gqlScalar String */ -type MyUrl = string; +export type MyUrl = string; ----------------- OUTPUT diff --git a/src/tests/integrationFixtures/customScalars/index.ts b/src/tests/integrationFixtures/customScalars/index.ts new file mode 100644 index 00000000..18c75204 --- /dev/null +++ b/src/tests/integrationFixtures/customScalars/index.ts @@ -0,0 +1,56 @@ +import { Maybe } from "@graphql-tools/utils"; +import { ValueNode } from "graphql"; +import { ObjMap } from "graphql/jsutils/ObjMap"; + +/** @gqlType */ +type Query = unknown; + +/** + * @gqlScalar + */ +export type DateTime = Date; + +/** + * @gqlField + */ +export function echo(_: Query, args: { in: DateTime }): DateTime { + return args.in; +} + +/** + * @gqlField + */ +export function now(_: Query): DateTime { + return new Date(); +} + +export const config = { + scalars: { + DateTime: { + serialize: (value: DateTime): number => value.getTime(), + parseValue(value: unknown): DateTime { + if (typeof value !== "number") throw new Error("Date is not a number"); + return new Date(value); + }, + parseLiteral( + ast: ValueNode, + _variables?: Maybe>, + ): DateTime { + if (ast.kind !== "IntValue") throw new Error("Date is not IntValue"); + return new Date(ast.value); + }, + }, + }, +}; + +export const query = ` + query SomeQuery($in: DateTime!) { + echo(in: 1703926606365) + echoVar: echo(in: $in) + now + } + `; + +export const variables = { + in: 1703926606365, +}; diff --git a/src/tests/integrationFixtures/customScalars/index.ts.expected b/src/tests/integrationFixtures/customScalars/index.ts.expected new file mode 100644 index 00000000..563fc2ad --- /dev/null +++ b/src/tests/integrationFixtures/customScalars/index.ts.expected @@ -0,0 +1,70 @@ +----------------- +INPUT +----------------- +import { Maybe } from "@graphql-tools/utils"; +import { ValueNode } from "graphql"; +import { ObjMap } from "graphql/jsutils/ObjMap"; + +/** @gqlType */ +type Query = unknown; + +/** + * @gqlScalar + */ +export type DateTime = Date; + +/** + * @gqlField + */ +export function echo(_: Query, args: { in: DateTime }): DateTime { + return args.in; +} + +/** + * @gqlField + */ +export function now(_: Query): DateTime { + return new Date(); +} + +export const config = { + scalars: { + DateTime: { + serialize: (value: DateTime): number => value.getTime(), + parseValue(value: unknown): DateTime { + if (typeof value !== "number") throw new Error("Date is not a number"); + return new Date(value); + }, + parseLiteral( + ast: ValueNode, + _variables?: Maybe>, + ): DateTime { + if (ast.kind !== "IntValue") throw new Error("Date is not IntValue"); + return new Date(ast.value); + }, + }, + }, +}; + +export const query = ` + query SomeQuery($in: DateTime!) { + echo(in: 1703926606365) + echoVar: echo(in: $in) + now + } + `; + +export const variables = { + in: 1703926606365, +}; + +----------------- +OUTPUT +----------------- +{ + "data": { + "echo": null, // TODO: That's not write + "echoVar": 1703926606365, + "now": 1703926988440 // TODO: Remove this + } +} \ No newline at end of file diff --git a/src/tests/integrationFixtures/customScalars/schema.ts b/src/tests/integrationFixtures/customScalars/schema.ts new file mode 100644 index 00000000..fd24bc64 --- /dev/null +++ b/src/tests/integrationFixtures/customScalars/schema.ts @@ -0,0 +1,53 @@ +import { DateTime as DateTimeType } from "./index"; +import { echo as queryEchoResolver } from "./index"; +import { now as queryNowResolver } from "./index"; +import { GraphQLScalarValueParser, GraphQLScalarLiteralParser, GraphQLSchema, GraphQLObjectType, GraphQLScalarType, GraphQLScalarSerializer, GraphQLNonNull } from "graphql"; +type ScalarConfigType = { + serialize(outputValue: T): any; + parseValue: GraphQLScalarValueParser; + parseLiteral: GraphQLScalarLiteralParser; +}; +export type SchemaConfigType = { + scalars: { + DateTime: ScalarConfigType; + }; +}; +export function getSchema(config: SchemaConfigType): GraphQLSchema { + const DateTimeType: GraphQLScalarType = new GraphQLScalarType({ + name: "DateTime", + serialize: config.scalars.DateTime.serialize as GraphQLScalarSerializer, + parseValue: config.scalars.DateTime.parseValue, + parseLiteral: config.scalars.DateTime.parseLiteral + }); + const QueryType: GraphQLObjectType = new GraphQLObjectType({ + name: "Query", + fields() { + return { + echo: { + name: "echo", + type: DateTimeType, + args: { + in: { + name: "in", + type: new GraphQLNonNull(DateTimeType) + } + }, + resolve(source, args) { + return queryEchoResolver(source, args); + } + }, + now: { + name: "now", + type: DateTimeType, + resolve(source) { + return queryNowResolver(source); + } + } + }; + } + }); + return new GraphQLSchema({ + query: QueryType, + types: [QueryType, DateTimeType] + }); +} diff --git a/src/tests/test.ts b/src/tests/test.ts index 2be95c8f..d4b3efcd 100644 --- a/src/tests/test.ts +++ b/src/tests/test.ts @@ -196,7 +196,7 @@ const testDirs = [ const schemaModule = await import(schemaPath); - const actualSchema = schemaModule.getSchema(); + const actualSchema = schemaModule.getSchema(server.config); const schemaDiff = compareSchemas(actualSchema, schemaResult.value); diff --git a/website/docs/04-docblock-tags/08-scalars.mdx b/website/docs/04-docblock-tags/08-scalars.mdx index f6f01d8f..1fed5124 100644 --- a/website/docs/04-docblock-tags/08-scalars.mdx +++ b/website/docs/04-docblock-tags/08-scalars.mdx @@ -9,9 +9,13 @@ GraphQL custom sclars can be defined by placing a `@gqlScalar` docblock directly * A description of my custom scalar. * @gqlScalar */ -type MyCustomString = string; +export type MyCustomString = string; ``` +:::note +Grats requires that you export your scalar types so that it may import them into your schema module to define types for the scalars's serialization/deserialization functions. +::: + ## Built-In Scalars :::note @@ -35,45 +39,44 @@ class Math { ## Serialization and Parsing of Custom Scalars -Grats does not ([yet](https://github.com/captbaritone/grats/issues/66)) support a first-class way to define serialization and parsing logic for custom scalars. However, you can do this manually by modifying the schema after it is generated. +When you define a custom scalar, you aslo need to inform the GraphQL executor how to serialize the data (convert the value into something JSON serializable) and how to parse the data (convert a value provided as a variable into the value expected by field arguments). The tree functions you must define are: + +* `serialize` Converts the return value of a resolver into a JSON serializable value. +* `parseValue` Converts the value of a variable into the value expected by a field argument. +* `parseLiteral` Converts the value of a literal (included in the query text) into the value expected by a field argument. -For example if you had a `Date` type in your schema: +Grats ensures you provide serializaiton/deseiralization functions for each of your custom scalars by requiring that you pass them when you call `getSchema`. + +### Custom Scalars Example: + +For example if you define a `Date` custom scalar type in your code: ```ts title="scalars.ts" /** @gqlScalar Date */ export type GqlDate = Date; ``` -To define a custom `serialize/parseValue/parseLiteral` transform for this type, which serialized the data as a Unix timestamp, you could do the following: +The `getSchema` function that Grats generates will require that you pass a config object with a `scalars` property, which is an object with a `Date` property, which is an object specifying `serialize`/`parseValue`/`parseLiteral` transformation functions: ```ts title="server.ts" import { getSchema } from "./schema"; // Generated by Grats import { GqlDate } from "./scalars"; -const schema = getSchema(); - -const date = schema.getType("Date") as GraphQLScalarType; - -date.serialize = (value) => { - if (!(value instanceof Date)) { - throw new Error("Date.serialize: value is not a Date object"); - } - return value.getTime(); -}; -date.parseValue = (value) => { - if (typeof value !== "number") { - throw new Error("Date.parseValue: value is not a number"); - } - return new Date(value); -}; -date.parseLiteral = (ast) => { - if (!(ast.kind === "IntValue" || ast.kind === "StringValue")) { - throw new Error( - "Date.parseLiteral: ast.kind is not IntValue or StringValue", - ); - } - return new Date(Number(ast.value)); -}; - + const schema = getSchema({ + scalars: { + Date: { + serialize: (value: GqlDate): number => value.getTime(), + parseValue(value: unknown): GqlDate { + if (typeof value !== "number") throw new Error("Date is not a number"); + return new Date(value); + }, + parseLiteral(ast: ValueNode): GqlDate { + if (ast.kind !== "IntValue") throw new Error("Date is not IntValue"); + return new Date(Number(ast.value)); + }, + }, + }, +}); // ... Continue on, using the schema to create a GraphQL server ``` +