From 65db1312d6b395700c203edeb50248b5e8a0c036 Mon Sep 17 00:00:00 2001 From: Bryan Pan Date: Fri, 11 Sep 2020 11:05:24 -0700 Subject: [PATCH] feat(appsync): add support for subscriptions for code-first schema generation (#10078) Implemented an `addSubscription` function for the `appsync.Schema` class to make easy schema generation. ```ts api.addSubscription('addedFilm', new appsync.ResolvableField({ returnType: film.attribute(), args: { id: appsync.GraphqlType.id({ isRequired: true }) }, directive: [appsync.Directive.subscribe('addFilm')], })); ``` Fixes: #9345 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-appsync/README.md | 22 ++++++ .../@aws-cdk/aws-appsync/lib/graphqlapi.ts | 23 +++++-- .../@aws-cdk/aws-appsync/lib/schema-base.ts | 59 +++++++++++++--- packages/@aws-cdk/aws-appsync/lib/schema.ts | 39 +++++++++-- .../aws-appsync/test/appsync-schema.test.ts | 67 ++++++++++++++++++- .../test/integ.graphql-schema.expected.json | 2 +- .../aws-appsync/test/integ.graphql-schema.ts | 5 ++ 7 files changed, 195 insertions(+), 22 deletions(-) diff --git a/packages/@aws-cdk/aws-appsync/README.md b/packages/@aws-cdk/aws-appsync/README.md index 3e3403d66f59f..0c2896a6cd2e1 100644 --- a/packages/@aws-cdk/aws-appsync/README.md +++ b/packages/@aws-cdk/aws-appsync/README.md @@ -779,3 +779,25 @@ api.addMutation('addFilm', new appsync.ResolvableField({ ``` To learn more about top level operations, check out the docs [here](https://docs.aws.amazon.com/appsync/latest/devguide/graphql-overview.html). + +#### Subscription + +Every schema **can** have a top level Subscription type. The top level `Subscription` Type +is the only exposed type that users can access to invoke a response to a mutation. `Subscriptions` +notify users when a mutation specific mutation is called. This means you can make any data source +real time by specify a GraphQL Schema directive on a mutation. + +**Note**: The AWS AppSync client SDK automatically handles subscription connection management. + +To add fields for these subscriptions, we can simply run the `addSubscription` function to add +to the schema's `Subscription` type. + +```ts +api.addSubscription('addedFilm', new appsync.ResolvableField({ + returnType: film.attribute(), + args: { id: appsync.GraphqlType.id({ isRequired: true }) }, + directive: [appsync.Directive.subscribe('addFilm')], +})); +``` + +To learn more about top level operations, check out the docs [here](https://docs.aws.amazon.com/appsync/latest/devguide/real-time-data.html). diff --git a/packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts b/packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts index 3b99fbaed592c..235c5abe369f3 100644 --- a/packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts +++ b/packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts @@ -594,8 +594,8 @@ export class GraphqlApi extends GraphqlApiBase { } /** - * Add a query field to the schema's Query. If one isn't set by - * the user, CDK will create an Object Type called 'Query'. For example, + * Add a query field to the schema's Query. CDK will create an + * Object Type called 'Query'. For example, * * type Query { * fieldName: Field.returnType @@ -609,8 +609,8 @@ export class GraphqlApi extends GraphqlApiBase { } /** - * Add a mutation field to the schema's Mutation. If one isn't set by - * the user, CDK will create an Object Type called 'Mutation'. For example, + * Add a mutation field to the schema's Mutation. CDK will create an + * Object Type called 'Mutation'. For example, * * type Mutation { * fieldName: Field.returnType @@ -622,4 +622,19 @@ export class GraphqlApi extends GraphqlApiBase { public addMutation(fieldName: string, field: ResolvableField): ObjectType { return this.schema.addMutation(fieldName, field); } + + /** + * Add a subscription field to the schema's Subscription. CDK will create an + * Object Type called 'Subscription'. For example, + * + * type Subscription { + * fieldName: Field.returnType + * } + * + * @param fieldName the name of the Subscription + * @param field the resolvable field to for this Subscription + */ + public addSubscription(fieldName: string, field: ResolvableField): ObjectType { + return this.schema.addSubscription(fieldName, field); + } } diff --git a/packages/@aws-cdk/aws-appsync/lib/schema-base.ts b/packages/@aws-cdk/aws-appsync/lib/schema-base.ts index 96e537a17d815..a639c747ca208 100644 --- a/packages/@aws-cdk/aws-appsync/lib/schema-base.ts +++ b/packages/@aws-cdk/aws-appsync/lib/schema-base.ts @@ -169,6 +169,18 @@ export interface IIntermediateType { addField(options: AddFieldOptions): void; } +interface DirectiveOptions { + /** + * The authorization type of this directive + */ + readonly mode?: AuthorizationType; + + /** + * Mutation fields for a subscription directive + */ + readonly mutationFields?: string[]; +} + /** * Directives for types * @@ -181,21 +193,21 @@ export class Directive { * Add the @aws_iam directive */ public static iam(): Directive { - return new Directive('@aws_iam', AuthorizationType.IAM); + return new Directive('@aws_iam', { mode: AuthorizationType.IAM }); } /** * Add the @aws_oidc directive */ public static oidc(): Directive { - return new Directive('@aws_oidc', AuthorizationType.OIDC); + return new Directive('@aws_oidc', { mode: AuthorizationType.OIDC }); } /** * Add the @aws_api_key directive */ public static apiKey(): Directive { - return new Directive('@aws_api_key', AuthorizationType.API_KEY); + return new Directive('@aws_api_key', { mode: AuthorizationType.API_KEY }); } /** @@ -209,9 +221,25 @@ export class Directive { } // this function creates the cognito groups as a string (i.e. ["group1", "group2", "group3"]) const stringify = (array: string[]): string => { - return array.reduce((acc, element) => `${acc}"${element}", `, '[').slice(0, -2) + ']'; + return array.reduce((acc, element) => `${acc}"${element}", `, '').slice(0, -2); + }; + return new Directive(`@aws_auth(cognito_groups: [${stringify(groups)}])`, { mode: AuthorizationType.USER_POOL }); + } + + /** + * Add the @aws_subscribe directive. Only use for top level Subscription type. + * + * @param mutations the mutation fields to link to + */ + public static subscribe(...mutations: string[]): Directive { + if (mutations.length === 0) { + throw new Error(`Subscribe directive requires at least one mutation field to be supplied. Received: ${mutations.length}`); + } + // this function creates the subscribe directive as a string (i.e. ["mutation_field_1", "mutation_field_2"]) + const stringify = (array: string[]): string => { + return array.reduce((acc, mutation) => `${acc}"${mutation}", `, '').slice(0, -2); }; - return new Directive(`@aws_auth(cognito_groups: ${stringify(groups)})`, AuthorizationType.USER_POOL); + return new Directive(`@aws_subscribe(mutations: [${stringify(mutations)}])`, { mutationFields: mutations }); } /** @@ -223,6 +251,20 @@ export class Directive { return new Directive(statement); } + /** + * The authorization type of this directive + * + * @default - not an authorization directive + */ + public readonly mode?: AuthorizationType; + + /** + * Mutation fields for a subscription directive + * + * @default - not a subscription directive + */ + public readonly mutationFields?: string[]; + /** * the directive statement */ @@ -233,11 +275,10 @@ export class Directive { */ protected modes?: AuthorizationType[]; - private readonly mode?: AuthorizationType; - - private constructor(statement: string, mode?: AuthorizationType) { + private constructor(statement: string, options?: DirectiveOptions) { this.statement = statement; - this.mode = mode; + this.mode = options?.mode; + this.mutationFields = options?.mutationFields; } /** diff --git a/packages/@aws-cdk/aws-appsync/lib/schema.ts b/packages/@aws-cdk/aws-appsync/lib/schema.ts index e1b7d763b8ad7..ec5bbfa14241d 100644 --- a/packages/@aws-cdk/aws-appsync/lib/schema.ts +++ b/packages/@aws-cdk/aws-appsync/lib/schema.ts @@ -109,8 +109,8 @@ export class Schema { } /** - * Add a query field to the schema's Query. If one isn't set by - * the user, CDK will create an Object Type called 'Query'. For example, + * Add a query field to the schema's Query. CDK will create an + * Object Type called 'Query'. For example, * * type Query { * fieldName: Field.returnType @@ -121,7 +121,7 @@ export class Schema { */ public addQuery(fieldName: string, field: ResolvableField): ObjectType { if (this.mode !== SchemaMode.CODE) { - throw new Error(`Unable to add query. Schema definition mode must be ${SchemaMode.CODE} Received: ${this.mode}`); + throw new Error(`Unable to add query. Schema definition mode must be ${SchemaMode.CODE}. Received: ${this.mode}`); } if (!this.query) { this.query = new ObjectType('Query', { definition: {} }); @@ -132,8 +132,8 @@ export class Schema { } /** - * Add a mutation field to the schema's Mutation. If one isn't set by - * the user, CDK will create an Object Type called 'Mutation'. For example, + * Add a mutation field to the schema's Mutation. CDK will create an + * Object Type called 'Mutation'. For example, * * type Mutation { * fieldName: Field.returnType @@ -144,7 +144,7 @@ export class Schema { */ public addMutation(fieldName: string, field: ResolvableField): ObjectType { if (this.mode !== SchemaMode.CODE) { - throw new Error(`Unable to add mutation. Schema definition mode must be ${SchemaMode.CODE} Received: ${this.mode}`); + throw new Error(`Unable to add mutation. Schema definition mode must be ${SchemaMode.CODE}. Received: ${this.mode}`); } if (!this.mutation) { this.mutation = new ObjectType('Mutation', { definition: {} }); @@ -154,6 +154,33 @@ export class Schema { return this.mutation; } + /** + * Add a subscription field to the schema's Subscription. CDK will create an + * Object Type called 'Subscription'. For example, + * + * type Subscription { + * fieldName: Field.returnType + * } + * + * @param fieldName the name of the Subscription + * @param field the resolvable field to for this Subscription + */ + public addSubscription(fieldName: string, field: ResolvableField): ObjectType { + if (this.mode !== SchemaMode.CODE) { + throw new Error(`Unable to add subscription. Schema definition mode must be ${SchemaMode.CODE}. Received: ${this.mode}`); + } + if (!this.subscription) { + this.subscription = new ObjectType('Subscription', { definition: {} }); + this.addType(this.subscription); + } + const directives = field.fieldOptions?.directives?.filter((directive) => directive.mutationFields); + if (directives && directives.length > 1) { + throw new Error(`Subscription fields must not have more than one @aws_subscribe directives. Received: ${directives.length}`); + } + this.subscription.addField({ fieldName, field }); + return this.subscription; + } + /** * Add type to the schema * diff --git a/packages/@aws-cdk/aws-appsync/test/appsync-schema.test.ts b/packages/@aws-cdk/aws-appsync/test/appsync-schema.test.ts index 95fe1ac7c500a..fafc4e20c2e8a 100644 --- a/packages/@aws-cdk/aws-appsync/test/appsync-schema.test.ts +++ b/packages/@aws-cdk/aws-appsync/test/appsync-schema.test.ts @@ -124,6 +124,56 @@ describe('basic testing schema definition mode `code`', () => { Definition: 'schema {\n mutation: Mutation\n}\ntype Mutation {\n test: String\n}\n', }); }); + + test('definition mode `code` allows for api to addSubscription', () => { + // WHEN + const api = new appsync.GraphqlApi(stack, 'API', { + name: 'demo', + }); + api.addSubscription('test', new appsync.ResolvableField({ + returnType: t.string, + })); + + // THEN + expect(stack).toHaveResourceLike('AWS::AppSync::GraphQLSchema', { + Definition: 'schema {\n subscription: Subscription\n}\ntype Subscription {\n test: String\n}\n', + }); + }); + + test('definition mode `code` allows for schema to addSubscription', () => { + // WHEN + const schema = new appsync.Schema(); + new appsync.GraphqlApi(stack, 'API', { + name: 'demo', + schema, + }); + schema.addSubscription('test', new appsync.ResolvableField({ + returnType: t.string, + })); + + // THEN + expect(stack).toHaveResourceLike('AWS::AppSync::GraphQLSchema', { + Definition: 'schema {\n subscription: Subscription\n}\ntype Subscription {\n test: String\n}\n', + }); + }); + + test('definition mode `code` addSubscription w/ @aws_subscribe', () => { + // WHE + const api = new appsync.GraphqlApi(stack, 'API', { + name: 'demo', + }); + api.addSubscription('test', new appsync.ResolvableField({ + returnType: t.string, + directives: [appsync.Directive.subscribe('test1')], + })); + + const out = 'schema {\n subscription: Subscription\n}\ntype Subscription {\n test: String\n @aws_subscribe(mutations: ["test1"])\n}\n'; + + // THEN + expect(stack).toHaveResourceLike('AWS::AppSync::GraphQLSchema', { + Definition: out, + }); + }); }); describe('testing schema definition mode `file`', () => { @@ -194,7 +244,7 @@ describe('testing schema definition mode `file`', () => { // THEN expect(() => { api.addQuery('blah', new appsync.ResolvableField({ returnType: t.string })); - }).toThrowError('Unable to add query. Schema definition mode must be CODE Received: FILE'); + }).toThrowError('Unable to add query. Schema definition mode must be CODE. Received: FILE'); }); test('definition mode `file` errors when addMutation is called', () => { @@ -207,6 +257,19 @@ describe('testing schema definition mode `file`', () => { // THEN expect(() => { api.addMutation('blah', new appsync.ResolvableField({ returnType: t.string })); - }).toThrowError('Unable to add mutation. Schema definition mode must be CODE Received: FILE'); + }).toThrowError('Unable to add mutation. Schema definition mode must be CODE. Received: FILE'); + }); + + test('definition mode `file` errors when addSubscription is called', () => { + // WHEN + const api = new appsync.GraphqlApi(stack, 'API', { + name: 'demo', + schema: appsync.Schema.fromAsset(join(__dirname, 'appsync.test.graphql')), + }); + + // THEN + expect(() => { + api.addSubscription('blah', new appsync.ResolvableField({ returnType: t.string })); + }).toThrowError('Unable to add subscription. Schema definition mode must be CODE. Received: FILE'); }); }); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.expected.json b/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.expected.json index 012ff6c80258f..1e49940a8f92a 100644 --- a/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.expected.json +++ b/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.expected.json @@ -16,7 +16,7 @@ "ApiId" ] }, - "Definition": "schema {\n query: Query\n mutation: Mutation\n}\ninterface Node {\n created: String\n edited: String\n id: ID!\n}\ntype Planet {\n name: String\n diameter: Int\n rotationPeriod: Int\n orbitalPeriod: Int\n gravity: String\n population: [String]\n climates: [String]\n terrains: [String]\n surfaceWater: Float\n created: String\n edited: String\n id: ID!\n}\ntype Species implements Node {\n name: String\n classification: String\n designation: String\n averageHeight: Float\n averageLifespan: Int\n eyeColors: [String]\n hairColors: [String]\n skinColors: [String]\n language: String\n homeworld: Planet\n created: String\n edited: String\n id: ID!\n}\ntype Query {\n getPlanets: [Planet]\n}\ntype Mutation {\n addPlanet(name: String diameter: Int rotationPeriod: Int orbitalPeriod: Int gravity: String population: [String] climates: [String] terrains: [String] surfaceWater: Float): Planet\n}\ninput AwesomeInput {\n awesomeInput: String\n}\nenum Episodes {\n The_Phantom_Menace\n Attack_of_the_Clones\n Revenge_of_the_Sith\n A_New_Hope\n The_Empire_Strikes_Back\n Return_of_the_Jedi\n The_Force_Awakens\n The_Last_Jedi\n The_Rise_of_Skywalker\n}\nunion Union = Species | Planet\n" + "Definition": "schema {\n query: Query\n mutation: Mutation\n subscription: Subscription\n}\ninterface Node {\n created: String\n edited: String\n id: ID!\n}\ntype Planet {\n name: String\n diameter: Int\n rotationPeriod: Int\n orbitalPeriod: Int\n gravity: String\n population: [String]\n climates: [String]\n terrains: [String]\n surfaceWater: Float\n created: String\n edited: String\n id: ID!\n}\ntype Species implements Node {\n name: String\n classification: String\n designation: String\n averageHeight: Float\n averageLifespan: Int\n eyeColors: [String]\n hairColors: [String]\n skinColors: [String]\n language: String\n homeworld: Planet\n created: String\n edited: String\n id: ID!\n}\ntype Query {\n getPlanets: [Planet]\n}\ntype Mutation {\n addPlanet(name: String diameter: Int rotationPeriod: Int orbitalPeriod: Int gravity: String population: [String] climates: [String] terrains: [String] surfaceWater: Float): Planet\n}\ntype Subscription {\n addedPlanets(id: ID!): Planet\n @aws_subscribe(mutations: [\"addPlanet\"])\n}\ninput AwesomeInput {\n awesomeInput: String\n}\nenum Episodes {\n The_Phantom_Menace\n Attack_of_the_Clones\n Revenge_of_the_Sith\n A_New_Hope\n The_Empire_Strikes_Back\n Return_of_the_Jedi\n The_Force_Awakens\n The_Last_Jedi\n The_Rise_of_Skywalker\n}\nunion Union = Species | Planet\n" } }, "codefirstapiDefaultApiKey89863A80": { diff --git a/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.ts b/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.ts index a33334dc6850b..4c02dd07f4a5a 100644 --- a/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.ts +++ b/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.ts @@ -103,6 +103,11 @@ api.addMutation('addPlanet', new appsync.ResolvableField({ responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(), })); +api.addSubscription('addedPlanets', new appsync.ResolvableField({ + returnType: planet.attribute(), + args: { id: ScalarType.required_id }, + directives: [appsync.Directive.subscribe('addPlanet')], +})); api.addType(new appsync.InputType('AwesomeInput', { definition: { awesomeInput: ScalarType.string }, }));