Skip to content

Commit

Permalink
feat(appsync): add support for subscriptions for code-first schema ge…
Browse files Browse the repository at this point in the history
…neration (#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*
  • Loading branch information
BryanPan342 committed Sep 11, 2020
1 parent 30a5b80 commit 65db131
Show file tree
Hide file tree
Showing 7 changed files with 195 additions and 22 deletions.
22 changes: 22 additions & 0 deletions packages/@aws-cdk/aws-appsync/README.md
Expand Up @@ -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).
23 changes: 19 additions & 4 deletions packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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);
}
}
59 changes: 50 additions & 9 deletions packages/@aws-cdk/aws-appsync/lib/schema-base.ts
Expand Up @@ -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
*
Expand All @@ -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 });
}

/**
Expand All @@ -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 });
}

/**
Expand All @@ -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
*/
Expand All @@ -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;
}

/**
Expand Down
39 changes: 33 additions & 6 deletions packages/@aws-cdk/aws-appsync/lib/schema.ts
Expand Up @@ -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
Expand All @@ -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: {} });
Expand All @@ -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
Expand All @@ -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: {} });
Expand All @@ -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
*
Expand Down
67 changes: 65 additions & 2 deletions packages/@aws-cdk/aws-appsync/test/appsync-schema.test.ts
Expand Up @@ -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`', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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');
});
});
Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.ts
Expand Up @@ -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 },
}));
Expand Down

0 comments on commit 65db131

Please sign in to comment.