DEV Community

Cover image for Getting started with a Angular/NX workspace backed by an AWS Amplify GraphQL API - Part 1
Michael Gustmann
Michael Gustmann

Posted on • Edited on

Getting started with a Angular/NX workspace backed by an AWS Amplify GraphQL API - Part 1

We are going to create a Todo app using Angular with the help of

  • Nx to help with a multi-app, multi-team project style
  • AWS Amplify CLI to create a fully featured backend, completely managed in code (infrastructure as code)
  • AWS AppSync JavaScript SDK An AppSync client using Apollo and featuring offline and persistence capabilities
  • AWS Amplify Angular Angular components and an Angular provider which helps integrate with the AWS-Amplify library

We are creating an offline-ready Todo-App backed by a GraphQL API and Authentication. We can expand on this app by adding further categories and features. But let's start simple.

You can take a look at the code in this repo.

Prerequisites

I'm assuming, that you have NodeJS version 8 or higher installed and that you have access to an AWS Account. The free tier is very generous during the first year and will probably cost you nothing.

Create a workspace

NX is a schematic on top of Angular CLI, that gives us tools to setup a workspace, where it is easy to share code between apps and to create boundaries to help working within a team or even multiple teams.

By separating out logic into modules these can be reused easily by just importing them like we are importing other npm packages. We don't need to publish any of those packages to some public or private repository. This saves us some headaches about running tests across different versions of each library and provides us a snapshot in our repo of all the parts working together.

With the AWS Amplify CLI we can even connect one or more cloud stacks to our workspace, so that we get 'Infrastructure as Code' of our backend going along with our frontend apps. It's not in the sense of having completely different products all put into the same repo. Rather thinking in terms of having a customer web- and separate mobile app, an admin portal, an electron desktop app for the support team, a manager reports app and so on... They all work with the same data in a way and would immensely profit from shared SDKs and public interfaces across libraries.

Even if you end up using just one app, where you include different domains or Lines of Businesses (LOB), it helps to structure the code base so that teams can work at the same time without worrying too much about the painful integration into production later.

We start by creating a workspace folder by executing the following command using npx, so that we don't have to globally install it

npx @nrwl/schematics amplify-graphql-angular-nx-todo
Enter fullscreen mode Exit fullscreen mode

When asked, we answer some questions

? Which stylesheet format would you like to use? CSS

? What is the npm scope you would like to use for your Nx Workspace? my

? What to create in the new workspace (You can create other applications and libraries at any point using 'ng g') empty

We named our workspace 'my' and it should be usually named after your organization or project, ie. 'myorg'.

We create an empty workspace, so that we can name our first application however we want. Otherwise it would get the name 'amplify-graphql-angular-nx-todo'.

Create an app

We decide to call our first app 'todo-app' (and it will be the only one for this post).

ng g app todo-app
Enter fullscreen mode Exit fullscreen mode

? What framework would you like to use for the application? Angular

? In which directory should the application be generated?

? Which stylesheet format would you like to use? CSS

? Which Unit Test Runner would you like to use for the application? Jest

? Which E2E Test Runner would you like to use for the application? Cypress

? Which tags would you like to add to the application? (used for linting) app

This created two apps

  1. todo-app
  2. todo-app-e2e

The second app is an end-to-end test. The app is our actual app and we will try to only use it for orchestrating other modules as much as possible. Most of our code we will put in libs to reuse it in possible further apps in the future.

Create libs

Where do we put our code then? NX makes it easy to create new libs! We compose our app out of possibly many libs.

  1. We need a lib for our backend stuff, let's call this lib appsync
  2. We put our UI components into the lib todo-ui

So let's run the following commands to generate our desired libs:

ng g lib appsync --tags=shared --framework=angular --unitTestRunner=jest --style=css
ng g lib todo-ui --tags=ui --framework=angular --unitTestRunner=jest --style=css
Enter fullscreen mode Exit fullscreen mode

Create the AWS backend

The (AWS Amplify CLI)[https://github.com/aws-amplify/amplify-cli] makes it very easy to create a serverless backend in the AWS cloud. Everything is defined in code and can be checked in our source control. It elevates the need to write lengthy CloudFormation templates that describe our cloud resources. A few simple commands will spin up entire cloud stacks using team resources or creating one-of developer sand-boxes to try out new features.

Install the Amplify CLI globally (if haven't done so previously) by running those commands:

npm install -g @aws-amplify/cli
amplify configure
Enter fullscreen mode Exit fullscreen mode

If you are completely new to AWS Amplify, you should check out the docs of how to configure the Amplify CLI

Here we actually install the AWS Amplify CLI globally instead of using npx! We will use the amplify command quite often.

Install the Amplify JavaScript dependencies

npm i aws-amplify aws-amplify-angular
Enter fullscreen mode Exit fullscreen mode

Adjusting the angular app to Amplify's SDK

There are a few things we need to change in order for us to use the AWS Amplify SDK in an Angular environment:

  1. Tell typescript about the window.global variable
  2. Add 'node' to tsconfig
  3. Write a script to rename aws-exports.js to aws-exports.ts.

Open apps/todo-app/src/polyfills.ts and add the following line at the top of the file

(window as any).global = window;
Enter fullscreen mode Exit fullscreen mode

The node package needs to be included in apps/todo-app/tsconfig.app.json

{
  "compilerOptions": {
    "types": ["node"]
  }
}
Enter fullscreen mode Exit fullscreen mode

AWS Amplify comes with an UI-Library. We might just as well reuse the styles from those components and build upon them. Add the following lines to apps/todo-app/src/styles.css:

/* You can add global styles to this file, and also import other style files */
@import '~aws-amplify-angular/theme.css';
Enter fullscreen mode Exit fullscreen mode

We connect our project or branch to a cloud resource stack by running

amplify init
Enter fullscreen mode Exit fullscreen mode

Using NX we need to change quite a few defaults:

? Enter a name for the project amplifynx
? Enter a name for the environment master
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using angular
? Source Directory Path: libs/appsync/src/l
? Distribution Directory Path: dist/apps/todo-app
? Build Command: npm run-script build
? Start Command: npm run-script start
Using default provider awscloudformation

? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use my.aws.profile
⠇ Initializing project in the cloud...

We named our cloud environment master, corresponding to our current git branch master. We can easily add further branches, like develop and create equally named environments (stacks) in the cloud.

We set the Source Directory Path to our previously created lib named appsync so that the automatically generated file aws_exports.js will land in this folder by default. This way we can share our cloud config with different apps by just importing this lib.

The Distribution Directory Path points to the dist output of a single app, which is only important, if we use the amplify CLI commands to build or serve the app or if we connect our app to Amplify Console. The latter makes it very easy to create a CI/CD Pipeline by just connecting a GIT branch to a deployment.

You will notice a new top-level folder named amplify. Most of the files created in this folder were added to .gitignore. The rest of the files can and should be added to version control and can be shared with your team. If you plan to make your repo public you should also add amplify/team-provider-info.json to .gitignore.

Let's add our first category auth!

amplify add auth
Enter fullscreen mode Exit fullscreen mode

I like to change the default configuration to soften the password policy and provide a custom email subject text, but you can choose the defaults if you like and later change any of those settings by running

amplify auth update
Enter fullscreen mode Exit fullscreen mode

To create these auth resources in the cloud run

amplify push
Enter fullscreen mode Exit fullscreen mode

You will see

Current Environment: master

| Category | Resource name | Operation | Provider plugin   |
| -------- | ------------- | --------- | ----------------- |
| Auth     | cognitonxtodo | Create    | awscloudformation |
? Are you sure you want to continue? (Y/n)
Enter fullscreen mode Exit fullscreen mode

And after a while you will find the newly created Cognito user pool when you log in to your AWS console.

This command will take you there:

amplify console auth
Enter fullscreen mode Exit fullscreen mode

Rename aws-exports.js

The amplify push command should have generated a file: libs/appsync/src/lib/aws-exports.js, which we should rename to aws-exports.ts to easily import the config in other typescript files. To automate this we use the scripts section in package.json. Now each time we either serve or build our app the file is renamed.

{
  "start": "npm run rename:aws:config || ng serve; ng serve",
  "build": "npm run rename:aws:config || ng build --prod; ng build --prod",
  "rename:aws:config": "cd libs/appsync/src/lib && [ -f aws-exports.js ] && mv aws-exports.js aws-exports.ts || true"
}
Enter fullscreen mode Exit fullscreen mode

We should also add this renamed file to .gitignore, so change aws-export.js to aws-exports.*s.

Adding a GraphQL API

If you have ever written a GraphQL API before you will appreciate the simplicity of creating one by just typing a few lines:

amplify add api
Enter fullscreen mode Exit fullscreen mode

? Please select from one of the below mentioned services GraphQL

? Provide API name: amplifynx

? Choose an authorization type for the API Amazon Cognito User Pool

Use a Cognito user pool configured as a part of this project

? Do you have an annotated GraphQL schema? No

? Do you want a guided schema creation? Yes

? What best describes your project: Single object with fields (e.g., “Todo” with ID, name, description)

? Do you want to edit the schema now? Yes

This will open your previously configured editor with a sample Todo schema:

type Todo @model {
  id: ID!
  name: String!
  description: String
}
Enter fullscreen mode Exit fullscreen mode

We don't want the description, but would like the completed status of our todos to be tracked.
At first to not worry too much about offline and sorting in the backend we can add a createdOnClientAt timestamp to sort the todos on the client. AppSync adds createdAt and updateAt timestamps by default, which we could use in the schema, but it will mess with our todo-list when we queue up several todos while we are offline and reconnect.

To explain this a little:

Say we only rely on the createdAt property to sort and we add todo-1 and todo-2 while offline. These get added to our outbox. They are sorted correctly, because we add a timestamp when we create each of those todos. Once we are online, todo-1 will be sent to the server and will receive a new createdAt timestamp on the server, since it was created there. This timestamp is at a later point in time than todo-2's timestamp. So todo-1 will jump to position 2 until todo-2 was transmitted. After todo-2 was sent to the server, getting a new timestamp, it will jump back to position 2 again. We will see a short reordering happen in our client.

The createdOnClientAt property is not touched on the server.

Change the schema to this and save:

type Todo @model @auth(rules: [{ allow: owner }]) {
  id: ID!
  name: String!
  completed: Boolean!
  createdOnClientAt: AWSDateTime!
}
Enter fullscreen mode Exit fullscreen mode

We added @auth(rules: [{allow: owner}]) to the Todo type to only show the todos of each logged in user. This will automatically add an owner field to each todo in the underlying todo table in DynamoDB.

What happened behind the scenes?

When you look into the amplify/backend/api/{angularnx}/build directory, you will find an oppiniated and enhanced GraphQL schema by the AWS Amplify team. It generated all types of CRUD operations and Input objects with filtering and pagination automatically! By just writing six lines of annotated graphql we get 116 lines of graphql best practices out of the box! That's just dandy!

Upload the API

When we decide to push our API for the first time we get asked a few questions about generating code and where to place it. Choose angular as language target and leave the rest as default!

amplify push
Enter fullscreen mode Exit fullscreen mode
Current Environment: master

| Category | Resource name | Operation | Provider plugin   |
| -------- | ------------- | --------- | ----------------- |
| Api      | amplifynx     | Create    | awscloudformation |
| Auth     | cognitonxtodo | No Change | awscloudformation |
? Are you sure you want to continue? Yes
Enter fullscreen mode Exit fullscreen mode

? Do you want to generate code for your newly created GraphQL API Yes

? Choose the code generation language target angular

? Enter the file name pattern of graphql queries, mutations and subscriptions (src/graphql//_.graphql)

_
? Enter the file name pattern of graphql queries, mutations and subscriptions*_ src/graphql/API.service.ts

_
*? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions*** Yes

⠋ Updating resources in the cloud. This may take a few minutes...

There is a bug when trying to use another directory, like our lib folder, so just leave the default paths src/graphql/*! Otherwise the service or types will end up in our lib and be pretty much empty and all *.graphql files will be in the default location.

Amplify Code Generator

We get two choices when we decide to generate code:

  1. angular
  2. typescript

1. angular

If we choose angular as the target (which we do in our example), we get a single file with all the typescript types and an angular service. This service uses the amplify SDK to connect to the API. It's not really useful to us, since we decided to use the AppSync SDK as our client instead to connect to our cloud resources to get offline and persistance out of the box. We won't use the types nor the service directly, but the operations defined in the service can provide a good starting point to write our own queries and mutations.

When choosing angular also generates queries.graphql, mutations.graphql, subscriptions.graphql and an introspection schema schema.json. This is very useful as we will see later on.

2. typescript

This generates *.ts files with all queries, mutations and subscriptions exported as strings.

It also generates a file API.ts that defines all the types.

I don't like this option as much as the other generators we will explore later. Here is an example generated by the typescript option:

export type CreateTodoMutation = {
  createTodo: {
    __typename: 'Todo';
    id: string;
    name: string;
    completed: boolean;
    createdOnClientAt: string;
  } | null;
};
Enter fullscreen mode Exit fullscreen mode

It's correct to have the nested createTodo property, but to type our Todo objects in code we need to do this:

const newTodo: CreateTodoMutation['createTodo'] = {
  /*...*/
};
Enter fullscreen mode Exit fullscreen mode

My code editor will not help with the string part and it might introduce subtle errors in complex schemas.

Apollo Code Generator

Another possibility is to use the apollo client's code generator. I like its generated types. They are clean, short and the above code would look like this

const newTodo: CreateTodo_createTodo = {
  /*...*/
};
Enter fullscreen mode Exit fullscreen mode

You can use it simply by executing

npx apollo-codegen generate src/graphql/*.graphql --schema src/graphql/schema.json --addTypename --target typescript --output libs/appsync/src/lib/api.types.ts
Enter fullscreen mode Exit fullscreen mode

Installing it locally and running it gives me an error, because of a version mismatch of the dependency graphql used in aws-appsync. Using npx works great, though and I didn't worry about finding another solution for this. Using yarn might work better...

GraphQL Code Generator

One very important feature I am looking for in a code generator is the possibility to customize how the code is generated. { GraphQL } code generator is such a tool. It uses plugins to generate code for lots of different languages.

You could install it by running

npm i graphql-code-generator graphql-codegen-typescript-common graphql-codegen-typescript-client
graphql-codegen-fragment-matcher
Enter fullscreen mode Exit fullscreen mode

Create a codegen.yml file:

overwrite: true
schema: src/graphql/schema.json
documents: src/graphql/*.graphql
generates:
  ./libs/appsync/src/lib/graphql.ts:
    plugins:
      - 'typescript-common'
      - 'typescript-client'
      - 'fragment-matcher'
Enter fullscreen mode Exit fullscreen mode

and simply run

npm run gql-gen
Enter fullscreen mode Exit fullscreen mode

This will generate a graphql.ts file in our lib providing all the types organized in namespaces. If we would add another plugin targeting angular we would also get a service for each operation, that we could simply inject in our components.

Note, we will NOT be using this tool in our app

Choosing the code generator

I think using the apollo code generator is probably the middle choice. It's easy enough to use when you add it the package.json's scripts section and hook it up to the amplify push command:

{
  "generate": "npx apollo-codegen generate src/graphql/*.graphql --schema src/graphql/schema.json --addTypename --target typescript --output libs/appsync/src/lib/api.types.ts",
  "push": "amplify push && npm run generate"
}
Enter fullscreen mode Exit fullscreen mode

Make sure to use angular (not typescript) as the target in the previous step when configuring amplify's codegen, so that the src/graphql/*.graphql files are generated.

The src/graphql folder is only used for code generation and should not be imported anywhere in our app!

Since everything in src/graphql is generated, we can also put it in .gitignore together with .graphqlconfig.yml.

src/graphql
.graphqlconfig.yml;
Enter fullscreen mode Exit fullscreen mode

Writing our GraphQL operations

Our app needs to

  1. Fetch our list of todos
  2. Create a new todo
  3. Update the completed status of a todo
  4. Delete a todo

To make our operations reusable in different components or services, we put them in a file libs/appsync/src/lib/gql-operations.ts.

import gql from 'graphql-tag';

export const CREATE_TODO = gql`
  mutation CreateTodo($input: CreateTodoInput!) {
    createTodo(input: $input) {
      __typename
      id
      name
      completed
      createdOnClientAt
    }
  }
`;

export const LIST_TODOS = gql`
  query ListTodos($filter: ModelTodoFilterInput, $nextToken: String) {
    listTodos(filter: $filter, limit: 999, nextToken: $nextToken) {
      __typename
      items {
        __typename
        id
        name
        completed
        createdOnClientAt
      }
    }
  }
`;

export const UPDATE_TODO = gql`
  mutation UpdateTodo($input: UpdateTodoInput!) {
    updateTodo(input: $input) {
      __typename
      id
      name
      completed
      createdOnClientAt
    }
  }
`;

export const DELETE_TODO = gql`
  mutation DeleteTodo($input: DeleteTodoInput!) {
    deleteTodo(input: $input) {
      __typename
      id
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

To not worry about fetching more todos and simplifying apollo's caching, we hard code the limit in listTodos to 999

Don't forget, that we have auto-generated code in src/graphql/API.service.ts so we can simply copy some of those statement and use them as our starting point. In a more advanced app with a deeply nested schema the generated operations will need to be adjusted on a per component need.

From a software architecture's point of view it would be better to create a new library called todo-data or todo-model and place the file there naming it todo-operations.ts. Then we could also add a facade which will be the only thing we would expose to the other libs. This data layer lib could also hold our local state using redux for example. Going this route would make our components simpler. We might expand on this in the future.

Setup the AppSync Client

Install the client, graphql-tag and optionally localforage

npm i aws-appsync graphql-tag localforage
Enter fullscreen mode Exit fullscreen mode

It's time to modify libs/appsync/src/lib/appsync.module.ts to instantiate and configure the AppSync client.

Change the content of the file to this:

import { NgModule } from '@angular/core';
import Amplify, { Auth } from 'aws-amplify';
import { AmplifyAngularModule, AmplifyService } from 'aws-amplify-angular';
import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync';
import * as localForage from 'localforage';
import config from './aws-exports';

Amplify.configure(config);

export const AppSyncClient = new AWSAppSyncClient({
  url: config.aws_appsync_graphqlEndpoint,
  region: config.aws_appsync_region,
  auth: {
    type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS,
    jwtToken: async () =>
      (await Auth.currentSession()).getIdToken().getJwtToken()
  },
  complexObjectsCredentials: () => Auth.currentCredentials(),
  cacheOptions: { addTypename: true },
  offlineConfig: { storage: localForage }
});

@NgModule({
  exports: [AmplifyAngularModule],
  providers: [AmplifyService]
})
export class AppsyncModule {}
Enter fullscreen mode Exit fullscreen mode

We are creating the AWSAppSyncClient and exporting the instance, so we can use it elsewhere.
AmplifyService needs to be added to the providers array, since it does not use @Injectable({providedIn: 'root'}).

To persist our the AppSync store in IndexedDB rather than LocalStorage, we use the library localforage.

To hook it up, don't forget to add AppsyncModule to the imports array of apps/todo-app/src/app/app.module.ts.

Note: We are not using the Apollo-Angular client here. This would give us an injectable service we could use throughout our app and which wraps the underlying zen-observables with rxjs. This is not necessary though, as we will see building this app in angular.

AWSAppSyncClient uses quite a few libraries under the hood, which makes it difficult to exchange or add some of them. I haven't found a way to use apollo-link-state and apollo-angular-link-http together with AWSAppSyncClient while retaining all TypScript typings.
This means NO Angular HttpClient and features like interceptors when working directly with the AWSAppSyncClient!

Offline helpers

The AWS AppSync SDK offers helpers to build mutation objects to use with apollo-client. This makes it quite easy to work with an optimistic UI in bad network conditions and update the local InMemoryCache when changes happen in our app.

There are higher level helpers available for react, but when using other libraries, we need to use the lower level function buildMutation. This function needs an apollo-client instance for example, which we can abstract away in our own typescript functions. Since we have generated types for our API we can also use these to give us additional type safety when providing __typename strings.

I also find it easier to use a config object instead of using a long parameter list. Here are my TypeScript Offline Mutation Builders which you can put in a file libs/appsync/src/lib/offline.helpers.ts:

import { OperationVariables } from 'apollo-client';
import {
  buildMutation,
  CacheOperationTypes,
  CacheUpdatesOptions,
  VariablesInfo
} from 'aws-appsync';
import { DocumentNode } from 'graphql';
import { AppSyncClient } from './appsync.module';

export interface TypeNameMutationType {
  __typename: string;
}

export interface GraphqlMutationInput<T, R extends TypeNameMutationType> {
  /** DocumentNode for the mutation */
  mutation: DocumentNode;
  /** An object with the mutation variables */
  variablesInfo: T | VariablesInfo<T>;
  /** The queries to update in the cache */
  cacheUpdateQuery: CacheUpdatesOptions;
  /** __typename from your schema */
  typename: R['__typename'];
  /** The name of the field with the ID (optional) */
  idField?: string;
  /** Override for the operation type (optional) */
  operationType?: CacheOperationTypes;
}

/**
 * Builds a MutationOptions object ready to be used by the ApolloClient to automatically update the cache according to the cacheUpdateQuery
 * parameter
 */
export function graphqlMutation<
  T = OperationVariables,
  R extends TypeNameMutationType = TypeNameMutationType
>({
  mutation,
  variablesInfo,
  cacheUpdateQuery,
  typename,
  idField,
  operationType
}: GraphqlMutationInput<T, R>) {
  return buildMutation<T>(
    AppSyncClient,
    mutation,
    variablesInfo,
    cacheUpdateQuery,
    typename,
    idField,
    operationType
  );
}

export function executeMutation<
  T = OperationVariables,
  R extends TypeNameMutationType = TypeNameMutationType
>(mutationInput: GraphqlMutationInput<T, R>) {
  return AppSyncClient.mutate(graphqlMutation<T, R>(mutationInput));
}
Enter fullscreen mode Exit fullscreen mode

Note, this might break with future versions of the appsync sdk

Export everything from our lib

To make it possible to import our types, graphql operations, helpers, appsync-client and amplify from our barrel we need to export them in the libs/appsync/src/index.ts file.

export * from './lib/appsync.module';
export * from './lib/api.types';
export * from './lib/gql-operations';
export * from './lib/offline.helpers';
Enter fullscreen mode Exit fullscreen mode

To import them in our components we can simply write

import { /* ... */ } from '@my/appsync';
Enter fullscreen mode Exit fullscreen mode

Recap

So far, we have setup our Angular multi-app workspace with NX, initialized AWS Amplify to give us a managed authentication service using AWS Cognito and generated a serverless GraphQL API using AWS AppSync. Just with a few CLI commands!

To make it possible to reuse our backend in more than one app, we imported and configured everything in a separate custom library.

Since GraphQL works with a schema we could even generate all typings automatically and copy query and mutation operations as a starting point for our own operational needs.

We also wrote some utility functions to help with the Apollo client-side cache in offline scenarios.

What to expect in part 2

In part 2 we will create a UI and use the stuff we created here to connect to our backend.

Top comments (1)

Collapse
 
davidrinck profile image
David Rinck

This is great! I've been going through the Full Stack Serverless book, written in React, and trying to write it in Angular and Nx. I was scratching my head on several steps and these posts helped me considerably.

One typo: When running amplify init the Source Directory Path:libs/appsync/src/l should be libs/appsync/src/lib