diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ca1c667..ccb7b90b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - **Breaking Change**: change the default `PrintSchemaOptions` option `commentDescriptions` to false (no more `#` comments in SDL) - add support for passing `PrintSchemaOptions` in `buildSchema.emitSchemaFile` (e.g. `commentDescriptions: true` to restore previous behavior) +- add `buildTypeDefsAndResolvers` utils function for generating apollo-like `typeDefs` and `resolvers` pair (#233) ## v0.16.0 ### Features diff --git a/docs/bootstrap.md b/docs/bootstrap.md index 773dea667..ea6d1bd92 100644 --- a/docs/bootstrap.md +++ b/docs/bootstrap.md @@ -78,3 +78,37 @@ bootstrap(); Remember to install `apollo-server` package from npm - it's not bundled with TypeGraphQL. Of course you can use `express-graphql` middleware, `graphql-yoga` or whatever you want 😉 + +## Create typeDefs and resolvers map + +TypeGraphQL also provides a second way to generate the GraphQL schema - the `buildTypeDefsAndResolvers` function. + +It accepts the same `BuildSchemaOptions` like the `buildSchema` function but instead of an executable `GraphQLSchema`, it creates a typeDefs and resolversMap pair that you can use e.g. with [`graphql-tools`](https://github.com/apollographql/graphql-tools): +```typescript +import { makeExecutableSchema } from "graphql-tools"; + +const { typeDefs, resolvers } = await buildTypeDefsAndResolvers({ + resolvers: [FirstResolver, SecondResolver], +}); + +const schema = makeExecutableSchema({ typeDefs, resolvers }); +``` + +Or even with other libraries that expect the schema info in that shape, like [`apollo-link-state`](https://github.com/apollographql/apollo-link-state): +```typescript +import { withClientState } from 'apollo-link-state'; + +const { typeDefs, resolvers } = await buildTypeDefsAndResolvers({ + resolvers: [FirstResolver, SecondResolver], +}); + +const stateLink = withClientState({ + // ...other options like `cache` + typeDefs, + resolvers, +}); + +// ...the rest of `ApolloClient` initialization code +``` + +Be aware that some of the TypeGraphQL features (i.a. [query complexity](complexity.md)) might not work with `buildTypeDefsAndResolvers` approach because they use some low-level `graphql-js` features. diff --git a/src/index.ts b/src/index.ts index 8192975e4..fb050c016 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,17 +2,6 @@ export * from "./decorators"; export * from "./scalars"; export * from "./errors"; export * from "./interfaces"; - -export { buildSchema, buildSchemaSync, BuildSchemaOptions } from "./utils/buildSchema"; -export { - emitSchemaDefinitionFile, - emitSchemaDefinitionFileSync, -} from "./utils/emitSchemaDefinitionFile"; -export { - useContainer, - ContainerType, - ContainerGetter, - UseContainerOptions, -} from "./utils/container"; +export * from "./utils"; export { PubSubEngine } from "graphql-subscriptions"; diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 5316b7017..14307e7cf 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -5,5 +5,6 @@ export * from "./Complexity"; export * from "./Publisher"; export * from "./ResolverData"; export * from "./ResolverFilterData"; -export * from "./ResolverTopicData"; export * from "./ResolverInterface"; +export * from "./resolvers-map"; +export * from "./ResolverTopicData"; diff --git a/src/interfaces/resolvers-map.ts b/src/interfaces/resolvers-map.ts new file mode 100644 index 000000000..cd0bd65fa --- /dev/null +++ b/src/interfaces/resolvers-map.ts @@ -0,0 +1,30 @@ +import { + GraphQLScalarType, + GraphQLFieldResolver, + GraphQLTypeResolver, + GraphQLIsTypeOfFn, +} from "graphql"; + +export interface ResolversMap { + [key: string]: + | ResolverObject + | ResolverOptions + | GraphQLScalarType + | EnumResolver; +} + +export interface ResolverObject { + [key: string]: ResolverOptions; +} + +export interface EnumResolver { + [key: string]: string | number; +} + +export interface ResolverOptions { + fragment?: string; + resolve?: GraphQLFieldResolver; + subscribe?: GraphQLFieldResolver; + __resolveType?: GraphQLTypeResolver; + __isTypeOf?: GraphQLIsTypeOfFn; +} diff --git a/src/utils/buildTypeDefsAndResolvers.ts b/src/utils/buildTypeDefsAndResolvers.ts new file mode 100644 index 000000000..abafb39df --- /dev/null +++ b/src/utils/buildTypeDefsAndResolvers.ts @@ -0,0 +1,11 @@ +import { printSchema } from "graphql"; + +import { BuildSchemaOptions, buildSchema } from "./buildSchema"; +import { createResolversMap } from "./createResolversMap"; + +export async function buildTypeDefsAndResolvers(options: BuildSchemaOptions) { + const schema = await buildSchema(options); + const typeDefs = printSchema(schema); + const resolvers = createResolversMap(schema); + return { typeDefs, resolvers }; +} diff --git a/src/utils/createResolversMap.ts b/src/utils/createResolversMap.ts new file mode 100644 index 000000000..2bb306174 --- /dev/null +++ b/src/utils/createResolversMap.ts @@ -0,0 +1,86 @@ +import { + GraphQLScalarType, + GraphQLEnumType, + GraphQLObjectType, + GraphQLInterfaceType, + GraphQLUnionType, + GraphQLFieldMap, + GraphQLSchema, + GraphQLTypeResolver, + GraphQLAbstractType, +} from "graphql"; + +import { ResolversMap, EnumResolver, ResolverObject } from "../interfaces"; + +export function createResolversMap(schema: GraphQLSchema): ResolversMap { + const typeMap = schema.getTypeMap(); + return Object.keys(typeMap) + .filter(typeName => !typeName.includes("__")) + .reduce((resolversMap, typeName) => { + const type = typeMap[typeName]; + if (type instanceof GraphQLObjectType) { + resolversMap[typeName] = { + __isTypeOf: type.isTypeOf || undefined, + ...generateFieldsResolvers(type.getFields()), + }; + } + if (type instanceof GraphQLInterfaceType) { + resolversMap[typeName] = { + __resolveType: generateTypeResolver(type, schema), + ...generateFieldsResolvers(type.getFields()), + }; + } + if (type instanceof GraphQLScalarType) { + resolversMap[typeName] = type; + } + if (type instanceof GraphQLEnumType) { + const enumValues = type.getValues(); + resolversMap[typeName] = enumValues.reduce((enumMap, { name, value }) => { + enumMap[name] = value; + return enumMap; + }, {}); + } + if (type instanceof GraphQLUnionType) { + resolversMap[typeName] = { + __resolveType: generateTypeResolver(type, schema), + }; + } + return resolversMap; + }, {}); +} + +function generateTypeResolver( + abstractType: GraphQLAbstractType, + schema: GraphQLSchema, +): GraphQLTypeResolver { + if (abstractType.resolveType) { + return async (source, context, info) => { + const detectedType = await abstractType.resolveType!(source, context, info); + if (detectedType instanceof GraphQLObjectType) { + return detectedType.name; + } + return detectedType; + }; + } + + const possibleObjectTypes = schema.getPossibleTypes(abstractType); + return async (source, context, info) => { + for (const objectType of possibleObjectTypes) { + if (objectType.isTypeOf && (await objectType.isTypeOf(source, context, info))) { + return objectType.name; + } + } + return null; + }; +} + +function generateFieldsResolvers(fields: GraphQLFieldMap): ResolverObject { + return Object.keys(fields).reduce((fieldsMap, fieldName) => { + const field = fields[fieldName]; + fieldsMap[fieldName] = { + subscribe: field.subscribe, + resolve: field.resolve, + }; + return fieldsMap; + }, {}); +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 000000000..a8e84217b --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,4 @@ +export { buildSchema, buildSchemaSync, BuildSchemaOptions } from "./buildSchema"; +export { buildTypeDefsAndResolvers } from "./buildTypeDefsAndResolvers"; +export { emitSchemaDefinitionFile, emitSchemaDefinitionFileSync } from "./emitSchemaDefinitionFile"; +export { useContainer, ContainerType, ContainerGetter, UseContainerOptions } from "./container"; diff --git a/tests/functional/typedefs-resolvers.ts b/tests/functional/typedefs-resolvers.ts new file mode 100644 index 000000000..c76b95d46 --- /dev/null +++ b/tests/functional/typedefs-resolvers.ts @@ -0,0 +1,528 @@ +import "reflect-metadata"; +import { + IntrospectionSchema, + graphql, + getIntrospectionQuery, + IntrospectionQuery, + IntrospectionInterfaceType, + TypeKind, + IntrospectionObjectType, + IntrospectionInputObjectType, + Kind, + IntrospectionNamedTypeRef, + IntrospectionEnumType, + IntrospectionUnionType, + IntrospectionScalarType, + execute, + GraphQLSchema, + subscribe, + ExecutionResult, +} from "graphql"; +import { makeExecutableSchema } from "graphql-tools"; +import { PubSub } from "graphql-subscriptions"; +import { MinLength } from "class-validator"; +import Container, { Service } from "typedi"; +import gql from "graphql-tag"; + +import { getMetadataStorage } from "../../src/metadata/getMetadataStorage"; +import { + Resolver, + Query, + buildTypeDefsAndResolvers, + InterfaceType, + ObjectType, + Field, + registerEnumType, + Subscription, + PubSubEngine, + Arg, + createUnionType, + Mutation, + Root, + InputType, + Authorized, + UseMiddleware, + ResolversMap, + useContainer, +} from "../../src"; + +describe("buildTypeDefsAndResolvers", () => { + const timestamp = 1547398942902; + let typeDefs: string; + let resolvers: ResolversMap; + let schemaIntrospection: IntrospectionSchema; + let schema: GraphQLSchema; + let pubSub: PubSubEngine; + let inputValue: any; + let enumValue: any; + let middlewareLogs: string[]; + + beforeEach(async () => { + middlewareLogs = []; + enumValue = undefined; + }); + + beforeAll(async () => { + getMetadataStorage().clear(); + + @Service() + class SampleService { + getSampleString() { + return "SampleString"; + } + } + + @InterfaceType() + abstract class SampleInterface { + @Field() + sampleInterfaceStringField: string; + } + + @ObjectType({ implements: SampleInterface }) + class SampleType1 implements SampleInterface { + @Field() + sampleInterfaceStringField: string; + @Field({ description: "sampleType1StringFieldDescription" }) + sampleType1StringField: string; + } + + @ObjectType({ implements: SampleInterface }) + class SampleType2 implements SampleInterface { + @Field() + sampleInterfaceStringField: string; + @Field({ deprecationReason: "sampleType2StringFieldDeprecation" }) + sampleType2StringField: string; + } + + @ObjectType() + class SampleType3 { + @Field() + sampleInterfaceStringField: string; + @Field() + sampleType3StringField: string; + } + + @InputType() + class SampleInput { + @Field() + @MinLength(10) + sampleInputStringField: string; + @Field() + sampleInputDefaultStringField: string = "sampleInputDefaultStringField"; + } + + enum SampleNumberEnum { + OptionOne, + OptionTwo, + } + registerEnumType(SampleNumberEnum, { name: "SampleNumberEnum" }); + + enum SampleStringEnum { + OptionOne = "OptionOneString", + OptionTwo = "OptionTwoString", + } + registerEnumType(SampleStringEnum, { name: "SampleStringEnum" }); + + const SampleUnion = createUnionType({ + types: [SampleType2, SampleType3], + name: "SampleUnion", + description: "SampleUnion description", + }); + + @Resolver() + class SampleResolver { + constructor(private readonly sampleService: SampleService) {} + + @Query({ description: "sampleDateQueryDescription" }) + sampleDateQuery(): Date { + return new Date(timestamp); + } + + @Query() + sampleServiceQuery(): string { + return this.sampleService.getSampleString(); + } + + @Query() + @UseMiddleware(async (_, next) => { + middlewareLogs.push("sampleMiddlewareBooleanQuery"); + return next(); + }) + sampleMiddlewareBooleanQuery(): boolean { + return true; + } + + @Mutation() + sampleBooleanMutation(): boolean { + return true; + } + + @Mutation() + sampleMutationWithInput(@Arg("input") input: SampleInput): boolean { + inputValue = input; + return true; + } + + @Mutation() + @Authorized() + sampleAuthorizedMutation(): boolean { + return true; + } + + @Query() + sampleInterfaceQuery(): SampleInterface { + const type1 = new SampleType1(); + type1.sampleInterfaceStringField = "sampleInterfaceStringField"; + type1.sampleType1StringField = "sampleType1StringField"; + + return type1; + } + + @Query(returns => SampleUnion) + sampleUnionQuery(): typeof SampleUnion { + const type3 = new SampleType3(); + type3.sampleInterfaceStringField = "sampleInterfaceStringField"; + type3.sampleType3StringField = "sampleType3StringField"; + + return type3; + } + + @Query(returns => SampleNumberEnum) + sampleNumberEnumQuery( + @Arg("numberEnum", type => SampleNumberEnum) numberEnum: SampleNumberEnum, + ): SampleNumberEnum { + enumValue = numberEnum; + return numberEnum; + } + + @Query(returns => SampleStringEnum) + sampleStringEnumQuery( + @Arg("stringEnum", type => SampleStringEnum) stringEnum: SampleStringEnum, + ): SampleStringEnum { + enumValue = stringEnum; + return stringEnum; + } + + @Subscription({ + topics: "SAMPLE", + }) + sampleSubscription(@Root() payload: number): number { + return payload; + } + } + + useContainer(Container); + + pubSub = new PubSub(); + ({ typeDefs, resolvers } = await buildTypeDefsAndResolvers({ + resolvers: [SampleResolver], + authChecker: () => false, + pubSub, + })); + schema = makeExecutableSchema({ + typeDefs, + resolvers, + }); + const introspectionResult = await graphql(schema, getIntrospectionQuery()); + schemaIntrospection = introspectionResult.data!.__schema; + }); + + it("should generate schema without errors", () => { + expect(schemaIntrospection).toBeDefined(); + }); + + describe("typeDefs", () => { + it("should generate typeDefs correctly", async () => { + expect(typeDefs).toBeDefined(); + }); + + it("should generate interface type", async () => { + const sampleInterface = schemaIntrospection.types.find( + it => it.name === "SampleInterface", + ) as IntrospectionInterfaceType; + + expect(sampleInterface.kind).toBe(TypeKind.INTERFACE); + expect(sampleInterface.fields).toHaveLength(1); + expect(sampleInterface.fields[0].name).toBe("sampleInterfaceStringField"); + expect(sampleInterface.possibleTypes).toHaveLength(2); + expect(sampleInterface.possibleTypes.map(it => it.name)).toContain("SampleType1"); + expect(sampleInterface.possibleTypes.map(it => it.name)).toContain("SampleType2"); + }); + + it("should generate object types", async () => { + const sampleType1 = schemaIntrospection.types.find( + it => it.name === "SampleType1", + ) as IntrospectionObjectType; + const sampleType2 = schemaIntrospection.types.find( + it => it.name === "SampleType2", + ) as IntrospectionObjectType; + const sampleType1StringField = sampleType1.fields.find( + it => it.name === "sampleType1StringField", + )!; + const sampleType2StringField = sampleType2.fields.find( + it => it.name === "sampleType2StringField", + )!; + + expect(sampleType1.kind).toBe(TypeKind.OBJECT); + expect(sampleType1.fields).toHaveLength(2); + expect(sampleType1StringField.description).toEqual("sampleType1StringFieldDescription"); + expect(sampleType1.interfaces).toHaveLength(1); + expect(sampleType1.interfaces[0].name).toBe("SampleInterface"); + expect(sampleType2StringField.deprecationReason).toBe("sampleType2StringFieldDeprecation"); + }); + + it("should generate input type", async () => { + const sampleInput = schemaIntrospection.types.find( + it => it.name === "SampleInput", + ) as IntrospectionInputObjectType; + const sampleInputDefaultStringField = sampleInput.inputFields.find( + it => it.name === "sampleInputDefaultStringField", + )!; + const sampleInputDefaultStringFieldType = sampleInputDefaultStringField.type as IntrospectionNamedTypeRef; + + expect(sampleInput.kind).toBe(TypeKind.INPUT_OBJECT); + expect(sampleInput.inputFields).toHaveLength(2); + expect(sampleInputDefaultStringFieldType.name).toBe("String"); + expect(sampleInputDefaultStringField.defaultValue).toBe('"sampleInputDefaultStringField"'); + }); + + it("should generate enum types", async () => { + const sampleNumberEnum = schemaIntrospection.types.find( + it => it.name === "SampleNumberEnum", + ) as IntrospectionEnumType; + const sampleStringEnum = schemaIntrospection.types.find( + it => it.name === "SampleStringEnum", + ) as IntrospectionEnumType; + + expect(sampleNumberEnum.kind).toBe(TypeKind.ENUM); + expect(sampleNumberEnum).toBeDefined(); + expect(sampleNumberEnum.enumValues).toHaveLength(2); + expect(sampleStringEnum.enumValues).toHaveLength(2); + }); + + it("should generate union type", async () => { + const sampleUnion = schemaIntrospection.types.find( + it => it.name === "SampleUnion", + ) as IntrospectionUnionType; + + expect(sampleUnion.kind).toBe(TypeKind.UNION); + expect(sampleUnion.description).toBe("SampleUnion description"); + expect(sampleUnion.possibleTypes).toHaveLength(2); + expect(sampleUnion.possibleTypes.map(it => it.name)).toContain("SampleType2"); + expect(sampleUnion.possibleTypes.map(it => it.name)).toContain("SampleType3"); + }); + + it("should generate queries", async () => { + const queryType = schemaIntrospection.types.find( + it => it.name === schemaIntrospection.queryType.name, + ) as IntrospectionObjectType; + + expect(queryType.fields).toHaveLength(7); + }); + + it("should generate mutations", async () => { + const mutationType = schemaIntrospection.types.find( + it => it.name === schemaIntrospection.mutationType!.name, + ) as IntrospectionObjectType; + + expect(mutationType.fields).toHaveLength(3); + }); + + it("should generate subscription", async () => { + const subscriptionType = schemaIntrospection.types.find( + it => it.name === schemaIntrospection.subscriptionType!.name, + ) as IntrospectionObjectType; + + expect(subscriptionType.fields).toHaveLength(1); + }); + + it("should emit Date scalar", async () => { + const dateScalar = schemaIntrospection.types.find( + it => it.name === "DateTime", + ) as IntrospectionScalarType; + + expect(dateScalar.kind).toBe(TypeKind.SCALAR); + }); + }); + + describe("resolvers", () => { + it("should generate resolversMap without errors", async () => { + expect(resolvers).toBeDefined(); + }); + + it("should properly serialize Date scalar", async () => { + const document = gql` + query { + sampleDateQuery + } + `; + + const { data } = await execute(schema, document); + const parsedDate = new Date(data.sampleDateQuery); + + expect(typeof data.sampleDateQuery).toBe("string"); + expect(parsedDate.getTime()).toEqual(timestamp); + }); + + it("should use container to resolve dependency", async () => { + const document = gql` + query { + sampleServiceQuery + } + `; + + const { data } = await execute(schema, document); + + expect(data.sampleServiceQuery).toEqual("SampleString"); + }); + + it("should run resolver method middleware", async () => { + const document = gql` + query { + sampleMiddlewareBooleanQuery + } + `; + + const { data } = await execute(schema, document); + + expect(data.sampleMiddlewareBooleanQuery).toEqual(true); + expect(middlewareLogs).toHaveLength(1); + expect(middlewareLogs[0]).toEqual("sampleMiddlewareBooleanQuery"); + }); + + it("should allow for simple boolean mutation", async () => { + const document = gql` + mutation { + sampleBooleanMutation + } + `; + + const { data } = await execute(schema, document); + + expect(data.sampleBooleanMutation).toBe(true); + }); + + it("should properly transform input argument", async () => { + const document = gql` + mutation { + sampleMutationWithInput(input: { sampleInputStringField: "sampleInputStringField" }) + } + `; + + const { data } = await execute(schema, document); + + expect(data.sampleMutationWithInput).toBe(true); + expect(inputValue.constructor.name).toBe("SampleInput"); + expect(inputValue.sampleInputStringField).toBe("sampleInputStringField"); + expect(inputValue.sampleInputDefaultStringField).toBe("sampleInputDefaultStringField"); + }); + + it("should validate the input", async () => { + const document = gql` + mutation { + sampleMutationWithInput(input: { sampleInputStringField: "short" }) + } + `; + + const { errors } = await execute(schema, document); + + expect(errors).toHaveLength(1); + expect(errors![0].message).toContain("Argument Validation Error"); + }); + + it("should properly guard authorized resolver method", async () => { + const document = gql` + mutation { + sampleAuthorizedMutation + } + `; + + const { errors } = await execute(schema, document); + + expect(errors).toHaveLength(1); + expect(errors![0].message).toContain("Access denied"); + }); + + it("should detect returned object type from interface", async () => { + const document = gql` + query { + sampleInterfaceQuery { + sampleInterfaceStringField + ... on SampleType1 { + sampleType1StringField + } + } + } + `; + + const { data } = await execute(schema, document); + + expect(data.sampleInterfaceQuery).toEqual({ + sampleInterfaceStringField: "sampleInterfaceStringField", + sampleType1StringField: "sampleType1StringField", + }); + }); + + it("should detect returned object type from union", async () => { + const document = gql` + query { + sampleUnionQuery { + ... on SampleType3 { + sampleInterfaceStringField + sampleType3StringField + } + } + } + `; + + const { data } = await execute(schema, document); + + expect(data.sampleUnionQuery).toEqual({ + sampleInterfaceStringField: "sampleInterfaceStringField", + sampleType3StringField: "sampleType3StringField", + }); + }); + + it("should properly transform number enum argument", async () => { + const document = gql` + query { + sampleNumberEnumQuery(numberEnum: OptionOne) + } + `; + + const { data } = await execute(schema, document); + + expect(data.sampleNumberEnumQuery).toBe("OptionOne"); + expect(enumValue).toBe(0); + }); + + it("should properly transform string enum argument", async () => { + const document = gql` + query { + sampleStringEnumQuery(stringEnum: OptionTwo) + } + `; + + const { data } = await execute(schema, document); + + expect(data.sampleStringEnumQuery).toBe("OptionTwo"); + expect(enumValue).toBe("OptionTwoString"); + }); + + it("should properly run subscriptions", async () => { + const document = gql` + subscription { + sampleSubscription + } + `; + const payload = 5.4321; + + const iterator = (await subscribe(schema, document)) as AsyncIterator; + const firstValuePromise = iterator.next(); + pubSub.publish("SAMPLE", payload); + const data = await firstValuePromise; + + expect(data.value.data!.sampleSubscription).toBe(payload); + }); + }); +});