Skip to content

Commit 28a9834

Browse files
authored
feat(appsync): support union types for code-first approach (#10025)
Support `Union Types` for code-first approach. `Union Types` are special types of Intermediate Types in CDK. <details> <summary>Desired GraphQL Union Type</summary> ```gql union Search = Human | Droid | Starship ``` </details> The above GraphQL Union Type can be expressed in CDK as the following: <details> <summary>CDK Code</summary> ```ts const human = new appsync.ObjectType('Human', { definition: {} }); const droid = new appsync.ObjectType('Droid', { definition: {} }); const starship = new appsync.ObjectType('Starship', { definition: {} }); const search = new appsync.UnionType('Search', { definition: [ human, droid, starship ], }); api.addType(search); ``` </details> ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 40222ef commit 28a9834

File tree

5 files changed

+287
-10
lines changed

5 files changed

+287
-10
lines changed

packages/@aws-cdk/aws-appsync/README.md

+29
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,7 @@ Intermediate Types include:
564564
- [**Interface Types**](#Interface-Types)
565565
- [**Object Types**](#Object-Types)
566566
- [**Input Types**](#Input-Types)
567+
- [**Union Types**](#Union-Types)
567568

568569
##### Interface Types
569570

@@ -669,6 +670,34 @@ api.addType(review);
669670

670671
To learn more about **Input Types**, read the docs [here](https://graphql.org/learn/schema/#input-types).
671672

673+
### Union Types
674+
675+
**Union Types** are a special type of Intermediate Type. They are similar to
676+
Interface Types, but they cannot specify any common fields between types.
677+
678+
**Note:** the fields of a union type need to be `Object Types`. In other words, you
679+
can't create a union type out of interfaces, other unions, or inputs.
680+
681+
```gql
682+
union Search = Human | Droid | Starship
683+
```
684+
685+
The above GraphQL Union Type encompasses the Object Types of Human, Droid and Starship. It
686+
can be expressed in CDK as the following:
687+
688+
```ts
689+
const string = appsync.GraphqlType.string();
690+
const human = new appsync.ObjectType('Human', { definition: { name: string } });
691+
const droid = new appsync.ObjectType('Droid', { definition: { name: string } });
692+
const starship = new appsync.ObjectType('Starship', { definition: { name: string } }););
693+
const search = new appsync.UnionType('Search', {
694+
definition: [ human, droid, starship ],
695+
});
696+
api.addType(search);
697+
```
698+
699+
To learn more about **Union Types**, read the docs [here](https://graphql.org/learn/schema/#union-types).
700+
672701
#### Query
673702

674703
Every schema requires a top level Query type. By default, the schema will look

packages/@aws-cdk/aws-appsync/lib/schema-intermediate.ts

+100-8
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,9 @@ export class InterfaceType implements IIntermediateType {
6969
}
7070

7171
/**
72-
* Create an GraphQL Type representing this Intermediate Type
72+
* Create a GraphQL Type representing this Intermediate Type
7373
*
7474
* @param options the options to configure this attribute
75-
* - isList
76-
* - isRequired
77-
* - isRequiredList
7875
*/
7976
public attribute(options?: BaseTypeOptions): GraphqlType {
8077
return GraphqlType.intermediate({
@@ -243,12 +240,9 @@ export class InputType implements IIntermediateType {
243240
}
244241

245242
/**
246-
* Create an GraphQL Type representing this Input Type
243+
* Create a GraphQL Type representing this Input Type
247244
*
248245
* @param options the options to configure this attribute
249-
* - isList
250-
* - isRequired
251-
* - isRequiredList
252246
*/
253247
public attribute(options?: BaseTypeOptions): GraphqlType {
254248
return GraphqlType.intermediate({
@@ -296,3 +290,101 @@ export class InputType implements IIntermediateType {
296290
this.definition[options.fieldName] = options.field;
297291
}
298292
}
293+
294+
/**
295+
* Properties for configuring an Union Type
296+
*
297+
* @param definition - the object types for this union type
298+
*
299+
* @experimental
300+
*/
301+
export interface UnionTypeOptions {
302+
/**
303+
* the object types for this union type
304+
*/
305+
readonly definition: IIntermediateType[];
306+
}
307+
308+
/**
309+
* Union Types are abstract types that are similar to Interface Types,
310+
* but they cannot to specify any common fields between types.
311+
*
312+
* Note that fields of a union type need to be object types. In other words,
313+
* you can't create a union type out of interfaces, other unions, or inputs.
314+
*
315+
* @experimental
316+
*/
317+
export class UnionType implements IIntermediateType {
318+
/**
319+
* the name of this type
320+
*/
321+
public readonly name: string;
322+
/**
323+
* the attributes of this type
324+
*/
325+
public readonly definition: { [key: string]: IField };
326+
/**
327+
* the authorization modes supported by this intermediate type
328+
*/
329+
protected modes?: AuthorizationType[];
330+
331+
public constructor(name: string, options: UnionTypeOptions) {
332+
this.name = name;
333+
this.definition = {};
334+
options.definition.map((def) => this.addField({ field: def.attribute() }));
335+
}
336+
337+
/**
338+
* Create a GraphQL Type representing this Union Type
339+
*
340+
* @param options the options to configure this attribute
341+
*/
342+
public attribute(options?: BaseTypeOptions): GraphqlType {
343+
return GraphqlType.intermediate({
344+
isList: options?.isList,
345+
isRequired: options?.isRequired,
346+
isRequiredList: options?.isRequiredList,
347+
intermediateType: this,
348+
});
349+
}
350+
351+
/**
352+
* Method called when the stringifying Intermediate Types for schema generation
353+
*
354+
* @internal
355+
*/
356+
public _bindToGraphqlApi(api: GraphqlApi): IIntermediateType {
357+
this.modes = api.modes;
358+
return this;
359+
}
360+
361+
/**
362+
* Generate the string of this Union type
363+
*/
364+
public toString(): string {
365+
// Return a string that appends all Object Types for this Union Type
366+
// i.e. 'union Example = example1 | example2'
367+
return Object.values(this.definition).reduce((acc, field) =>
368+
`${acc} ${field.toString()} |`, `union ${this.name} =`).slice(0, -2);
369+
}
370+
371+
/**
372+
* Add a field to this Union Type
373+
*
374+
* Input Types must have field options and the IField must be an Object Type.
375+
*
376+
* @param options the options to add a field
377+
*/
378+
public addField(options: AddFieldOptions): void {
379+
if (options.fieldName) {
380+
throw new Error('Union Types cannot be configured with the fieldName option. Use the field option instead.');
381+
}
382+
if (!options.field) {
383+
throw new Error('Union Types must be configured with the field option.');
384+
}
385+
if (options.field && !(options.field.intermediateType instanceof ObjectType)) {
386+
throw new Error('Fields for Union Types must be Object Types.');
387+
}
388+
this.definition[options.field?.toString() + 'id'] = options.field;
389+
}
390+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import '@aws-cdk/assert/jest';
2+
import * as cdk from '@aws-cdk/core';
3+
import * as appsync from '../lib';
4+
import * as t from './scalar-type-defintions';
5+
6+
const out = 'type Test1 {\n test1: String\n}\ntype Test2 {\n test2: String\n}\nunion UnionTest = Test1 | Test2\n';
7+
const test1 = new appsync.ObjectType('Test1', {
8+
definition: { test1: t.string },
9+
});
10+
const test2 = new appsync.ObjectType('Test2', {
11+
definition: { test2: t.string },
12+
});
13+
let stack: cdk.Stack;
14+
let api: appsync.GraphqlApi;
15+
beforeEach(() => {
16+
// GIVEN
17+
stack = new cdk.Stack();
18+
api = new appsync.GraphqlApi(stack, 'api', {
19+
name: 'api',
20+
});
21+
api.addType(test1);
22+
api.addType(test2);
23+
});
24+
25+
describe('testing Union Type properties', () => {
26+
test('UnionType configures properly', () => {
27+
// WHEN
28+
const union = new appsync.UnionType('UnionTest', {
29+
definition: [test1, test2],
30+
});
31+
api.addType(union);
32+
// THEN
33+
expect(stack).toHaveResourceLike('AWS::AppSync::GraphQLSchema', {
34+
Definition: `${out}`,
35+
});
36+
expect(stack).not.toHaveResource('AWS::AppSync::Resolver');
37+
});
38+
39+
test('UnionType can addField', () => {
40+
// WHEN
41+
const union = new appsync.UnionType('UnionTest', {
42+
definition: [test1],
43+
});
44+
api.addType(union);
45+
union.addField({ field: test2.attribute() });
46+
47+
// THEN
48+
expect(stack).toHaveResourceLike('AWS::AppSync::GraphQLSchema', {
49+
Definition: `${out}`,
50+
});
51+
});
52+
53+
test('UnionType errors when addField is configured with fieldName option', () => {
54+
// WHEN
55+
const union = new appsync.UnionType('UnionTest', {
56+
definition: [test1],
57+
});
58+
api.addType(union);
59+
60+
// THEN
61+
expect(() => {
62+
union.addField({ fieldName: 'fail', field: test2.attribute() });
63+
}).toThrowError('Union Types cannot be configured with the fieldName option. Use the field option instead.');
64+
});
65+
66+
test('UnionType errors when addField is not configured with field option', () => {
67+
// WHEN
68+
const union = new appsync.UnionType('UnionTest', {
69+
definition: [test1],
70+
});
71+
api.addType(union);
72+
73+
// THEN
74+
expect(() => {
75+
union.addField({});
76+
}).toThrowError('Union Types must be configured with the field option.');
77+
});
78+
79+
test('UnionType can be a GraphqlType', () => {
80+
// WHEN
81+
const union = new appsync.UnionType('UnionTest', {
82+
definition: [test1, test2],
83+
});
84+
api.addType(union);
85+
86+
api.addType(new appsync.ObjectType('Test2', {
87+
definition: { union: union.attribute() },
88+
}));
89+
90+
const obj = 'type Test2 {\n union: UnionTest\n}\n';
91+
92+
// THEN
93+
expect(stack).toHaveResourceLike('AWS::AppSync::GraphQLSchema', {
94+
Definition: `${out}${obj}`,
95+
});
96+
});
97+
98+
test('appsync errors when addField with Graphql Types', () => {
99+
// WHEN
100+
const test = new appsync.UnionType('Test', {
101+
definition: [],
102+
});
103+
// THEN
104+
expect(() => {
105+
test.addField({ field: t.string });
106+
}).toThrowError('Fields for Union Types must be Object Types.');
107+
});
108+
109+
test('appsync errors when addField with Field', () => {
110+
// WHEN
111+
const test = new appsync.UnionType('Test', {
112+
definition: [],
113+
});
114+
// THEN
115+
expect(() => {
116+
test.addField({ field: new appsync.Field({ returnType: t.string }) });
117+
}).toThrowError('Fields for Union Types must be Object Types.');
118+
});
119+
120+
test('appsync errors when addField with ResolvableField', () => {
121+
// WHEN
122+
const test = new appsync.UnionType('Test', {
123+
definition: [],
124+
});
125+
// THEN
126+
expect(() => {
127+
test.addField({ field: new appsync.ResolvableField({ returnType: t.string }) });
128+
}).toThrowError('Fields for Union Types must be Object Types.');
129+
});
130+
131+
test('appsync errors when addField with Interface Types', () => {
132+
// WHEN
133+
const test = new appsync.UnionType('Test', {
134+
definition: [],
135+
});
136+
// THEN
137+
expect(() => {
138+
test.addField({ field: new appsync.InterfaceType('break', { definition: {} }).attribute() });
139+
}).toThrowError('Fields for Union Types must be Object Types.');
140+
});
141+
142+
test('appsync errors when addField with Union Types', () => {
143+
// WHEN
144+
const test = new appsync.UnionType('Test', {
145+
definition: [],
146+
});
147+
// THEN
148+
expect(() => {
149+
test.addField({ field: test.attribute() });
150+
}).toThrowError('Fields for Union Types must be Object Types.');
151+
});
152+
});

packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.expected.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"ApiId"
1717
]
1818
},
19-
"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 input {\n awesomeInput: String\n}\n"
19+
"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 input {\n awesomeInput: String\n}\nunion Union = Species | Planet\n"
2020
}
2121
},
2222
"codefirstapiDefaultApiKey89863A80": {

packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const tableDS = api.addDynamoDbDataSource('planets', table);
4848
const planet = ObjectType.planet;
4949
schema.addType(planet);
5050

51-
api.addType(new appsync.ObjectType('Species', {
51+
const species = api.addType(new appsync.ObjectType('Species', {
5252
interfaceTypes: [node],
5353
definition: {
5454
name: ScalarType.string,
@@ -107,4 +107,8 @@ api.addType(new appsync.InputType('input', {
107107
definition: { awesomeInput: ScalarType.string },
108108
}));
109109

110+
api.addType(new appsync.UnionType('Union', {
111+
definition: [species, planet],
112+
}));
113+
110114
app.synth();

0 commit comments

Comments
 (0)