diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f8b2f41..285ebbdb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ # Changelog and release notes - +## Unreleased +### Features +- add support for arguments in fields of interfaces (#284) + ## v0.17.3 ### Features - update packages `semver` to `^6.0.0` and `graphql-subscriptions` to `^1.1.0` diff --git a/docs/interfaces.md b/docs/interfaces.md index ae01760be..bbb1958c1 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -34,13 +34,27 @@ We can then we use this "interface" in the object type class definition: ```typescript @ObjectType({ implements: IPerson }) -class Person implements IPerson { +class Person extends IPerson { id: string; name: string; age: number; } ``` +We can also define dynamic fields for an interface using the same syntax you would use when defining one for your object types: + +```typescript +@InterfaceType() +abstract class IPerson { + // ... + + @Field() + avatar(@Arg("size") size: number): string { + return `http://i.pravatar.cc/${size}`; + } +} +``` + The only difference is that we have to let TypeGraphQL know that this `ObjectType` is implementing the `InterfaceType`. We do this by passing the param `({ implements: IPerson })` to the decorator. If we implement multiple interfaces, we pass an array of interfaces like so: `({ implements: [IPerson, IAnimal, IMachine] })`. We can also omit the decorators since the GraphQL types will be copied from the interface definition - this way we won't have to maintain two definitions and solely rely on TypeScript type checking for correct interface implementation. diff --git a/examples/interfaces-inheritance/person/person.interface.ts b/examples/interfaces-inheritance/person/person.interface.ts index 6889c61da..fb79cf2cb 100644 --- a/examples/interfaces-inheritance/person/person.interface.ts +++ b/examples/interfaces-inheritance/person/person.interface.ts @@ -1,4 +1,4 @@ -import { InterfaceType, Field, Int, ID } from "../../../src"; +import { InterfaceType, Field, Int, ID, Arg } from "../../../src"; import { IResource } from "../resource/resource.interface"; @@ -12,4 +12,9 @@ export abstract class IPerson implements IResource { @Field(type => Int) age: number; + + @Field() + avatar(@Arg("size") size: number): string { + throw new Error("Method not implemented."); + } } diff --git a/examples/interfaces-inheritance/person/person.type.ts b/examples/interfaces-inheritance/person/person.type.ts index 4ece1b67e..c38eba7a3 100644 --- a/examples/interfaces-inheritance/person/person.type.ts +++ b/examples/interfaces-inheritance/person/person.type.ts @@ -1,4 +1,4 @@ -import { ObjectType } from "../../../src"; +import { ObjectType, Field, Arg } from "../../../src"; import { IPerson } from "./person.interface"; @@ -7,4 +7,9 @@ export class Person implements IPerson { id: string; name: string; age: number; + + @Field() + avatar(@Arg("size") size: number): string { + return `http://i.pravatar.cc/${size}`; + } } diff --git a/src/schema/schema-generator.ts b/src/schema/schema-generator.ts index 7a32d6073..ad320a395 100644 --- a/src/schema/schema-generator.ts +++ b/src/schema/schema-generator.ts @@ -211,9 +211,22 @@ export abstract class SchemaGenerator { fields: () => { let fields = interfaceType.fields!.reduce>( (fieldsMap, field) => { + const fieldResolverMetadata = getMetadataStorage().fieldResolvers.find( + resolver => + resolver.getObjectType!() === interfaceType.target && + resolver.methodName === field.name && + (resolver.resolverClassMetadata === undefined || + resolver.resolverClassMetadata.isAbstract === false), + ); fieldsMap[field.schemaName] = { - description: field.description, type: this.getGraphQLOutputType(field.name, field.getType(), field.typeOptions), + complexity: field.complexity, + args: this.generateHandlerArgs(field.params!), + resolve: fieldResolverMetadata + ? createAdvancedFieldResolver(fieldResolverMetadata) + : createSimpleFieldResolver(field), + description: field.description, + deprecationReason: field.deprecationReason, }; return fieldsMap; }, diff --git a/tests/functional/interfaces-with-arguments.ts b/tests/functional/interfaces-with-arguments.ts new file mode 100644 index 000000000..07a70a270 --- /dev/null +++ b/tests/functional/interfaces-with-arguments.ts @@ -0,0 +1,406 @@ +import "reflect-metadata"; +import { getMetadataStorage } from "../../src/metadata/getMetadataStorage"; +import { + IntrospectionSchema, + IntrospectionObjectType, + IntrospectionInterfaceType, + IntrospectionField, + GraphQLSchema, + graphql, +} from "graphql"; +import { getSchemaInfo } from "../helpers/getSchemaInfo"; +import { + Arg, + Args, + ArgsType, + Field, + ID, + Int, + InterfaceType, + ObjectType, + Query, + Resolver, + buildSchema, + FieldResolver, + Root, +} from "../../src"; + +describe("Interfaces with arguments", () => { + describe("Schema", () => { + let schemaIntrospection: IntrospectionSchema; + + beforeAll(async () => { + getMetadataStorage().clear(); + + @ArgsType() + class SampleArgs1 { + @Field(type => Int, { nullable: true, defaultValue: 50 }) + intValue1?: number; + @Field(type => Int, { nullable: true }) + intValue2?: number; + } + + @InterfaceType() + abstract class SampleInterface1 { + @Field(type => ID) + id: string; + @Field({ nullable: true }) + interfaceStringField1?: string; + + @Field() + interfaceFieldInlineArguments( + @Arg("intValue1") intValue1: number, + @Arg("intValue2") intValue2: number, + ): string { + throw new Error("Method not implemented."); + } + @Field() + interfaceFieldArgumentsType(@Args() args: SampleArgs1): string { + throw new Error("Method not implemented."); + } + } + + @ObjectType({ implements: SampleInterface1 }) + class SampleImplementingObject1 implements SampleInterface1 { + id: string; + interfaceStringField1?: string; + + @Field() + url: string; + + interfaceFieldInlineArguments(intValue1: number, intValue2: number): string { + return `${intValue1}-${intValue2}`; + } + interfaceFieldArgumentsType(args: SampleArgs1): string { + return `${args.intValue1}-${args.intValue2}`; + } + + @Field() + implemetingObjectTypeFieldInlineArguments( + @Arg("intValue1") intValue1: number, + @Arg("intValue2") intValue2: number, + ): string { + return `${intValue1}-${intValue2}`; + } + @Field() + implemetingObjectTypeFieldArgumentsType(@Args() args: SampleArgs1): string { + return `${args.intValue1}-${args.intValue2}`; + } + } + + @Resolver(of => SampleImplementingObject1) + class SampleResolver { + @Query(returns => [SampleImplementingObject1]) + sampleQuery(): SampleImplementingObject1[] { + return []; + } + + @FieldResolver(type => String) + sampleFieldResolver(@Root() sample: SampleImplementingObject1, @Args() args: SampleArgs1) { + return `${args.intValue1}-${args.intValue2}`; + } + } + + // get builded schema info from retrospection + const schemaInfo = await getSchemaInfo({ + resolvers: [SampleResolver], + }); + schemaIntrospection = schemaInfo.schemaIntrospection; + }); + + it("should generate schema without errors", async () => { + expect(schemaIntrospection).toBeDefined(); + }); + + it("should have proper arguments for the interface field with inline arguments", async () => { + const sampleField = (schemaIntrospection.types.find( + type => type.name === "SampleInterface1", + ) as IntrospectionInterfaceType).fields.find( + f => f.name === "interfaceFieldInlineArguments", + ) as IntrospectionField; + + expect(sampleField.args).toBeDefined(); + expect(sampleField.args.length).toEqual(2); + expect( + sampleField.args.every(arg => ["intValue1", "intValue2"].includes(arg.name)), + ).toBeTruthy(); + }); + + it("should have proper arguments for the interface field with an arguments type", async () => { + const sampleField = (schemaIntrospection.types.find( + type => type.name === "SampleInterface1", + ) as IntrospectionInterfaceType).fields.find( + f => f.name === "interfaceFieldArgumentsType", + ) as IntrospectionField; + + expect(sampleField.args).toBeDefined(); + expect(sampleField.args.length).toEqual(2); + expect( + sampleField.args.every(arg => ["intValue1", "intValue2"].includes(arg.name)), + ).toBeTruthy(); + expect(sampleField.args.find(a => a.name === "intValue1")!.defaultValue).toEqual("50"); + }); + + it("should have proper arguments for the object field with inline arguments", async () => { + const sampleField = (schemaIntrospection.types.find( + type => type.name === "SampleImplementingObject1", + ) as IntrospectionInterfaceType).fields.find( + f => f.name === "implemetingObjectTypeFieldInlineArguments", + ) as IntrospectionField; + + expect(sampleField.args).toBeDefined(); + expect(sampleField.args.length).toEqual(2); + expect( + sampleField.args.every(arg => ["intValue1", "intValue2"].includes(arg.name)), + ).toBeTruthy(); + }); + + it("should have proper arguments for the object field with an arguments type", async () => { + const sampleField = (schemaIntrospection.types.find( + type => type.name === "SampleImplementingObject1", + ) as IntrospectionInterfaceType).fields.find( + f => f.name === "implemetingObjectTypeFieldArgumentsType", + ) as IntrospectionField; + + expect(sampleField.args).toBeDefined(); + expect(sampleField.args.length).toEqual(2); + expect( + sampleField.args.every(arg => ["intValue1", "intValue2"].includes(arg.name)), + ).toBeTruthy(); + expect(sampleField.args.find(a => a.name === "intValue1")!.defaultValue).toEqual("50"); + }); + + it("should have proper arguments for the object field-resolver", async () => { + const sampleField = (schemaIntrospection.types.find( + type => type.name === "SampleImplementingObject1", + ) as IntrospectionInterfaceType).fields.find( + f => f.name === "sampleFieldResolver", + ) as IntrospectionField; + + expect(sampleField.args).toBeDefined(); + expect(sampleField.args.length).toEqual(2); + expect( + sampleField.args.every(arg => ["intValue1", "intValue2"].includes(arg.name)), + ).toBeTruthy(); + expect(sampleField.args.find(a => a.name === "intValue1")!.defaultValue).toEqual("50"); + }); + }); + + describe("Functional", () => { + const resolvedValueString = (intValue1?: number, intValue2?: number) => + `${intValue1}-${intValue2}`; + let schema: GraphQLSchema; + + beforeAll(async () => { + getMetadataStorage().clear(); + + @ArgsType() + class SampleArgs1 { + @Field(type => Int, { nullable: true, defaultValue: 50 }) + intValue1?: number; + @Field(type => Int, { nullable: true }) + intValue2?: number; + } + + @InterfaceType() + abstract class SampleInterface1 { + @Field(type => ID) + id: string; + @Field({ nullable: true }) + interfaceStringField1?: string; + + @Field() + interfaceFieldInlineArguments( + @Arg("intValue1") intValue1: number, + @Arg("intValue2") intValue2: number, + ): string { + throw new Error("Method not implemented."); + } + @Field() + interfaceFieldArgumentsType(@Args() args: SampleArgs1): string { + throw new Error("Method not implemented."); + } + } + + @ObjectType({ implements: SampleInterface1 }) + class SampleImplementingObject1 implements SampleInterface1 { + id: string; + interfaceStringField1?: string; + + @Field() + ownStringField1: string; + + constructor(id: string, ownStringField1: string) { + this.id = id; + this.ownStringField1 = ownStringField1; + } + + @Field() + interfaceFieldInlineArguments( + @Arg("intValue1") intValue1: number, + @Arg("intValue2") intValue2: number, + ): string { + return resolvedValueString(intValue1, intValue2); + } + @Field() + interfaceFieldArgumentsType(@Args() args: SampleArgs1): string { + return resolvedValueString(args.intValue1, args.intValue2); + } + + @Field() + implemetingObjectTypeFieldInlineArguments( + @Arg("intValue1") intValue1: number, + @Arg("intValue2") intValue2: number, + ): string { + return resolvedValueString(intValue1, intValue2); + } + @Field() + implemetingObjectTypeFieldArgumentsType(@Args() args: SampleArgs1): string { + return resolvedValueString(args.intValue1, args.intValue2); + } + } + + @Resolver(of => SampleImplementingObject1) + class SampleResolver { + @Query(returns => [SampleImplementingObject1]) + sampleQuery(): SampleImplementingObject1[] { + return [new SampleImplementingObject1("sampleId", "sampleString1")]; + } + + @FieldResolver(type => String) + sampleFieldResolver(@Root() sample: SampleImplementingObject1, @Args() args: SampleArgs1) { + return resolvedValueString(args.intValue1, args.intValue2); + } + } + + schema = await buildSchema({ + resolvers: [SampleResolver], + }); + }); + + it("should build the schema without errors", () => { + expect(schema).toBeDefined(); + }); + + it("should properly resolve the interface field with inline arguments", async () => { + const query = `query { + sampleQuery { + interfaceFieldInlineArguments(intValue1: 200, intValue2: 200) + } + }`; + + const response = await graphql(schema, query); + + const result = response.data!.sampleQuery; + expect(result).toBeDefined(); + expect(result.length).toEqual(1); + expect(result[0].interfaceFieldInlineArguments).toEqual(resolvedValueString(200, 200)); + }); + + it("should properly resolve the interface field with an arguments type", async () => { + const query = `query { + sampleQuery { + interfaceFieldArgumentsType(intValue1: 200, intValue2: 200) + } + }`; + + const response = await graphql(schema, query); + + const result = response.data!.sampleQuery; + expect(result).toBeDefined(); + expect(result.length).toEqual(1); + expect(result[0].interfaceFieldArgumentsType).toEqual(resolvedValueString(200, 200)); + }); + + it("should properly resolve the object field with inline arguments", async () => { + const query = `query { + sampleQuery { + ... on SampleImplementingObject1 { + implemetingObjectTypeFieldInlineArguments(intValue1: 200, intValue2: 200) + } + } + }`; + + const response = await graphql(schema, query); + + const result = response.data!.sampleQuery; + expect(result).toBeDefined(); + expect(result.length).toEqual(1); + expect(result[0].implemetingObjectTypeFieldInlineArguments).toEqual( + resolvedValueString(200, 200), + ); + }); + + it("should properly resolve the object field with an arguments type", async () => { + const query = `query { + sampleQuery { + ... on SampleImplementingObject1 { + implemetingObjectTypeFieldArgumentsType(intValue1: 200, intValue2: 200) + } + } + }`; + + const response = await graphql(schema, query); + + const result = response.data!.sampleQuery; + expect(result).toBeDefined(); + expect(result.length).toEqual(1); + expect(result[0].implemetingObjectTypeFieldArgumentsType).toEqual( + resolvedValueString(200, 200), + ); + }); + + it("should properly resolve field with nullable argument", async () => { + const query = `query { + sampleQuery { + ... on SampleImplementingObject1 { + implemetingObjectTypeFieldArgumentsType(intValue1: 200) + } + } + }`; + + const response = await graphql(schema, query); + + const result = response.data!.sampleQuery; + expect(result).toBeDefined(); + expect(result.length).toEqual(1); + expect(result[0].implemetingObjectTypeFieldArgumentsType).toEqual( + resolvedValueString(200, undefined), + ); + }); + + it("should properly resolve field with nullable argument but default value", async () => { + const query = `query { + sampleQuery { + ... on SampleImplementingObject1 { + implemetingObjectTypeFieldArgumentsType(intValue2: 200) + } + } + }`; + + const response = await graphql(schema, query); + + const result = response.data!.sampleQuery; + expect(result).toBeDefined(); + expect(result.length).toEqual(1); + expect(result[0].implemetingObjectTypeFieldArgumentsType).toEqual( + resolvedValueString(50, 200), + ); + }); + + it("should properly resolve a field-resolver", async () => { + const query = `query { + sampleQuery { + sampleFieldResolver(intValue1: 200, intValue2: 200) + } + }`; + + const response = await graphql(schema, query); + + const result = response.data!.sampleQuery; + expect(result).toBeDefined(); + expect(result.length).toEqual(1); + expect(result[0].sampleFieldResolver).toEqual(resolvedValueString(200, 200)); + }); + }); +});