Building a Amplify Flutter for Web using a Serverless CDK Backend in Typescript with AppSync Merged APIs
In this post you will learn how to build a simple website using Flutter that interacts with a serverless backend powered by AppSyncs Merged APIs. the project will be deployed using CDK and comes with a CodeCatalyst workflow
Used technologies in this project
Using AWS AppSync - what are Merged APIs?
Writing infrastructure as code using AWS CDK
Setting up the serverless backend
Persistance layer & authentication
The CDK code for DynamoDB and Cognito
APIs - the AppSync setup and Schema
Setting up your Flutter project using Amplify SDK
Mixing it all together: Our CI/CD pipeline in CodeCatalyst
Developer Experience and a minimal Continuous Integration pipeline
Continuous Deployment and promotion using CodeCatalyst workflows
A community driven example project
What you learned and what you should take away from this article
Our web-page (build in Flutter) will be hosted on Amazon S3 behind Cloudfront. Cognito will be used for Authentication. AppSync will be our API endpoint and we'll connect to DynamoDB as a database. A few API endpoints will have Lambda functions.
In the project you will use the Flutter Amplify SDK to connect the Flutter application to the serverless backend.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
createTable(namespace: string): Table {
const table = new Table(this, `BackendTable-${namespace}`, {
billingMode: BillingMode.PAY_PER_REQUEST,
partitionKey: {
name: 'pk',
type: AttributeType.STRING,
},
removalPolicy: RemovalPolicy.DESTROY,
sortKey: {
name: 'sk',
type: AttributeType.STRING,
},
stream: StreamViewType.NEW_IMAGE,
tableName: `backend-table-${namespace}`,
});
table.addGlobalSecondaryIndex({
indexName: 'gsi1',
partitionKey: {
name: 'gsi1pk',
type: AttributeType.STRING,
},
sortKey: {
name: 'gsi1sk',
type: AttributeType.STRING,
},
});
return table;
}
pk
(primary key), the sk
(sort key) to access your data. The pk
needs to be unique across all of your data.The model additional access patterns, you can use the secondary index
gsi1pk
and gsi1sk
.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
createUserPool(namespace: string): cognito.UserPool {
const postConfirmationLambda = new NodejsFunction(this, `${namespace}-post-confirmation-lambda`, {
entry: 'backend/src/auth/post-confirmation.ts',
runtime: Runtime.NODEJS_18_X,
bundling: {
externalModules: ['@aws-sdk/client-dynamodb', '@aws-sdk/lib-dynamodb'],
},
environment: {
TABLE_NAME: this.table.tableName,
},
});
this.table.grantReadWriteData(postConfirmationLambda);
const userPool = new cognito.UserPool(this, 'UserPool', {
selfSignUpEnabled: true,
removalPolicy: RemovalPolicy.DESTROY, // for development only
standardAttributes: {
email: {
required: true,
},
},
autoVerify: {
email: true,
},
keepOriginal: {
email: true,
},
lambdaTriggers: {
postConfirmation: postConfirmationLambda,
},
customAttributes: {
city: new cognito.StringAttribute(),
country: new cognito.StringAttribute(),
},
});
const groups = ['Speaker', 'Organizer', 'Admin'];
groups.forEach((groupName) => {
new cognito.CfnUserPoolGroup(this, `UserPoolGroupAdmin${groupName}`, {
groupName,
userPoolId: userPool.userPoolId,
});
});
userPool.addClient('UserPoolClientFrontend', {
authFlows: {
userPassword: true,
userSrp: true,
custom: true,
},
oAuth: {
flows: {
authorizationCodeGrant: true,
},
scopes: [
cognito.OAuthScope.OPENID,
cognito.OAuthScope.PHONE,
cognito.OAuthScope.EMAIL,
cognito.OAuthScope.PROFILE,
cognito.OAuthScope.COGNITO_ADMIN,
],
callbackUrls: ['https://example.lockhead.info', 'https://test.example.lockhead.info'],
logoutUrls: ['https://example.lockhead.info/logout', 'https://test.speakers.lockhead.info/logout'],
},
});
return userPool;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { PutCommand } from '@aws-sdk/lib-dynamodb';
import { docClient, tableName } from '../util/docClient';
import type { PostConfirmationTriggerEvent } from 'aws-lambda';
const handler = async (event: PostConfirmationTriggerEvent): Promise<PostConfirmationTriggerEvent> => {
try {
const item = {
pk: `user#${event.userName}`,
sk: `user#${event.userName}`,
username: event.userName,
email: event.request.userAttributes.email,
firstName: event.request.userAttributes.given_name,
lastName: event.request.userAttributes.family_name,
birthdate: event.request.userAttributes.birthdate,
gender: event.request.userAttributes.gender,
city: event.request.userAttributes['custom:city'],
country: event.request.userAttributes['custom:country'],
};
await docClient.send(
new PutCommand({
TableName: tableName,
Item: item,
}),
);
} catch (err) {
console.log(err);
throw new Error('Registration failed');
}
return event;
};
export { handler };
This feature is very powerful for bigger organizations that have very segregated teams. Since v2.94.0 this is also supported by the AWS CDK with an L2 construct:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import * as cdk from 'aws-cdk-lib';
// first source API
const firstApi = new appsync.GraphqlApi(this, 'FirstSourceAPI', {
name: 'FirstSourceAPI',
definition: appsync.Definition.fromFile(path.join(__dirname, 'appsync.merged-api-1.graphql')),
});
firstApi.addNoneDataSource('FirstSourceDS', {
name: cdk.Lazy.string({ produce(): string { return 'FirstSourceDS'; } }),
});
// second source API
const secondApi = new appsync.GraphqlApi(this, 'SecondSourceAPI', {
name: 'SecondSourceAPI',
definition: appsync.Definition.fromFile(path.join(__dirname, 'appsync.merged-api-2.graphql')),
});
secondApi.addNoneDataSource('SecondSourceDS', {
name: cdk.Lazy.string({ produce(): string { return 'SecondSourceDS'; } }),
});
const speakerDataSource = secondApi.addDynamoDbDataSource(`backend-datasource-${namespace}`, this.table);
createAppsyncResolver(
secondApi,
speakerDataSource,
'Query',
'getInformation',
'../lib/gql-functions/Query.getInformation.js',
);
// Merged API
const mergedApi = new appsync.GraphqlApi(this, 'MergedAPI', {
name: 'MergedAPI',
definition: appsync.Definition.fromSourceApis({
sourceApis: [
{
sourceApi: firstApi,
mergeType: appsync.MergeType.MANUAL_MERGE,
},
],
}),
});
createAppsyncResolver(
graphqlApi: GraphqlApi,
dataSource: DynamoDbDataSource,
type: string,
name: string,
pathToFunction: string,
): void {
const appsyncFunction = new AppsyncFunction(this, `${name}Function`, {
name: `${name}Function`,
api: graphqlApi,
dataSource,
runtime: FunctionRuntime.JS_1_0_0,
code: Code.fromAsset(path.join(__dirname, pathToFunction)),
});
new Resolver(this, `${name}FunctionPipelineResolver`, {
api: graphqlApi,
typeName: type,
fieldName: name,
code: Code.fromAsset(path.join(__dirname, '../lib/gql-functions/passThrough.js')),
runtime: FunctionRuntime.JS_1_0_0,
pipelineConfig: [appsyncFunction],
});
}
passThrough.js
function is a Javascript Appsync resolver that only passes through the request:1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { type Context } from '@aws-appsync/utils';
// The before step.
// This runs before ALL the AppSync functions in this pipeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function request(...args: string[]): any {
console.log(args);
return {};
}
// The AFTER step. This runs after ALL the AppSync functions in this pipeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function response(ctx: Context): any {
return ctx.prev.result;
}
getInformation
function accessed the DynamoDB table:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { type Speaker } from '../API';
// import slug from 'slug';
import { util, type DynamoDBQueryRequest, type Context } from '@aws-appsync/utils';
export function request(ctx: Context): DynamoDBQueryRequest {
if (ctx.identity == null) {
// util.error(String, String?, Object?, Object?)
util.error('Failed to fetch information - no identity found', 'IdentityMissing', ctx.prev.result);
return ctx.prev.result;
}
const speaker = ctx.args.speaker;
const pk = `user#${speaker.replaceAll('information#', '')}`;
// change to GetItem instead of Query because values.id contains that information
return {
operation: 'Query',
query: {
expression: '#pk = :pk and begins_with(#sk, :sk)',
expressionNames: {
'#pk': 'pk',
'#sk': 'sk',
},
expressionValues: util.dynamodb.toMapValues({
':pk': pk,
':sk': 'information#',
}),
},
};
}
export function response(ctx: Context): Information {
const response = ctx.result.items[0];
return response as Information;
}
flutter create frontend
. This will create an example application that has no connectivity to the backend.I would advice you to also use the Authenticator UI Library which simplifies the integration and avoids to implement the authentication/authorization flow.
amplifyconfiguration.dart
.This then concludes the implementation.
amplifyconfiguration.dart
for this project will look similar to this one:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class EnvironmentConfig {
static const WEB_URL = String.fromEnvironment('WEB_URL');
static const API_URL = String.fromEnvironment('API_URL');
static const CLIENT_ID = String.fromEnvironment('CLIENT_ID');
static const POOL_ID = String.fromEnvironment('POOL_ID');
}
const amplifyconfig = """{
"UserAgent": "aws-amplify-cli/2.0",
"Version": "1.0",
"api": {
"plugins": {
"awsAPIPlugin": {
"frontend": {
"endpointType": "GraphQL",
"endpoint": "${EnvironmentConfig.API_URL}",
"region": "us-east-1",
"authorizationType": "AMAZON_COGNITO_USER_POOLS"
}
}
}
},
"auth": {
"plugins": {
"awsCognitoAuthPlugin": {
"UserAgent": "aws-amplify-cli/0.1.0",
"Version": "0.1.0",
"IdentityManager": {
"Default": {}
},
"CognitoUserPool": {
"Default": {
"PoolId": "${EnvironmentConfig.POOL_ID}",
"AppClientId": "${EnvironmentConfig.CLIENT_ID}",
"Region": "us-east-1"
}
},
"Auth": {
"Default": {
"authenticationFlowType": "USER_PASSWORD_AUTH",
"socialProviders": [],
"usernameAttributes": [
"EMAIL"
],
"signupAttributes": [
"BIRTHDATE",
"EMAIL",
"FAMILY_NAME",
"NAME",
"NICKNAME",
"PREFERRED_USERNAME",
"WEBSITE",
"ZONEINFO"
],
"passwordProtectionSettings": {
"passwordPolicyMinLength": 8,
"passwordPolicyCharacters": []
},
"mfaConfiguration": "OFF",
"mfaTypes": [
"SMS"
],
"verificationMechanisms": [
"EMAIL"
]
}
}
}
}
}
}""";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const execOptions: ExecSyncOptions = {
stdio: ['ignore', process.stderr, 'inherit'],
};
const bundle = Source.asset('./frontend', {
bundling: {
command: ['sh', '-c', 'echo "Docker build not supported. Please install flutter."'],
image: DockerImage.fromRegistry('alpine'),
local: {
tryBundle(outputDir: string) {
try {
execSync('flutter --version', execOptions);
/* c8 ignore next 3 */
} catch {
return false;
}
execSync('cd frontend && flutter clean', execOptions);
execSync("cd frontend && git config --global --add safe.directory '*'", execOptions);
execSync('cd frontend && flutter pub get', execOptions);
execSync('cd frontend && flutter precache --web', execOptions);
// execSync('cd frontend && flutter test --platform chrome --coverage', execOptions);
execSync(
`cd frontend && flutter build web --no-tree-shake-icons --dart-define=WEB_URL=https://${domainName} --dart-define=API_URL=https://api.${domainName}/graphql --dart-define=CLIENT_ID=${userPool.clientId} --dart-define=POOL_ID=${userPool.poolId}`,
execOptions,
);
copySync('./frontend/build/web', outputDir);
return true;
},
},
},
});
new BucketDeployment(this, 'Deployment', {
destinationBucket: bucket,
distribution,
distributionPaths: ['/*'],
sources: [bundle],
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
Name: cdk-deployment
Compute:
Type: LAMBDA
RunMode: SUPERSEDED
SchemaVersion: "1.0"
Triggers:
- Type: PUSH
Branches:
- main
Actions:
BuildAndTestCDKApp:
Identifier: aws/github-actions-runner@v1
Compute:
Type: EC2
Inputs:
Sources:
- WorkflowSource
Variables:
- Name: AWS_REGION
Value: us-east-1
- Name: NODE_ENV
Value: sandbox
Outputs:
AutoDiscoverReports:
Enabled: true
ReportNamePrefix: rpt
IncludePaths:
- ./coverage/lcov.info
Configuration:
Steps:
- name: Flutter Build Web
uses: subosito/flutter-action@v2.8.0
with:
channel: stable
- name: Java Install
uses: actions/setup-java@v2
with:
distribution: zulu
java-version: "11"
- name: Setup Android SDK
uses: android-actions/setup-android@v2
- uses: actions/setup-node@v3
with:
node-version: 16
- run: npm ci
- run: npm test
Environment:
Connections:
- Role: CodeCatalystWorkflowDevelopmentRole-Sandbox
Name: sandbox-role
Name: sandbox_account
DeploySandboxCDKApp:
DependsOn:
- BuildAndTestCDKApp
Actions:
DeployBase:
Identifier: aws/github-actions-runner@v1
Compute:
Type: EC2
Inputs:
Sources:
- WorkflowSource
Variables:
- Name: AWS_REGION
Value: us-east-1
- Name: NODE_ENV
Value: sandbox
Configuration:
Steps:
- name: Flutter Build Web
uses: subosito/flutter-action@v2.8.0
with:
channel: stable
- name: Java Install
uses: actions/setup-java@v2
with:
distribution: zulu
java-version: "11"
- name: Setup Android SDK
uses: android-actions/setup-android@v2
- uses: actions/setup-node@v3
with:
node-version: 16
- run: npm ci
- run: npm run deploy
Environment:
Connections:
- Role: CodeCatalystWorkflowDevelopmentRole-Sandbox
Name: sandbox-role
Name: sandbox_account
IntegrationTestSandbox:
Identifier: aws/build@v1
Inputs:
Sources:
- WorkflowSource
Configuration:
Steps:
- Run: ls -al
Compute:
Type: Lambda
DependsOn:
- DeploySandboxCDKApp
DeployTestCDKApp:
DependsOn:
- IntegrationTestSandbox
Actions:
DeployBase:
Identifier: aws/github-actions-runner@v1
Compute:
Type: EC2
Inputs:
Sources:
- WorkflowSource
Variables:
- Name: AWS_REGION
Value: us-east-1
- Name: NODE_ENV
Value: test
Configuration:
Steps:
- name: Flutter Build Web
uses: subosito/flutter-action@v2.8.0
with:
channel: stable
- name: Java Install
uses: actions/setup-java@v2
with:
distribution: zulu
java-version: "11"
- name: Setup Android SDK
uses: android-actions/setup-android@v2
- uses: actions/setup-node@v3
with:
node-version: 16
- run: npm ci
- run: npm run deploy
Environment:
Connections:
- Role: CodeCatalystWorkflowDevelopmentRole-TestRole
Name: test-role
Name: test_account
IntegrationTestTest:
Identifier: aws/build@v1
Inputs:
Sources:
- WorkflowSource
Configuration:
Steps:
- Run: ls -al
Compute:
Type: Lambda
DependsOn:
- DeployTestCDKApp
DeployProdCDKApp:
DependsOn:
- IntegrationTestTest
Actions:
DeployBase:
Identifier: aws/github-actions-runner@v1
Compute:
Type: EC2
Inputs:
Sources:
- WorkflowSource
Variables:
- Name: AWS_REGION
Value: us-east-1
- Name: NODE_ENV
Value: prod
Configuration:
Steps:
- name: Flutter Build Web
uses: subosito/flutter-action@v2.8.0
with:
channel: stable
- name: Java Install
uses: actions/setup-java@v2
with:
distribution: zulu
java-version: "11"
- name: Setup Android SDK
uses: android-actions/setup-android@v2
- uses: actions/setup-node@v3
with:
node-version: 16
- run: npm ci
- run: npm run deploy
Environment:
Connections:
- Role: CodeCatalystWorkflowDevelopmentRole-ProductionRole
Name: role-production
Name: production_account
IntegrationTestProd:
Identifier: aws/build@v1
Inputs:
Sources:
- WorkflowSource
Configuration:
Steps:
- Run: ls -al
Compute:
Type: Lambda
DependsOn:
- DeployProdCDKApp