🐼

[Amplify, Angular ] 1時間でつくる認証付きチャットアプリ

2020/12/11に公開

最初に

AWS Amplifyと Angular(今回はionic フレームワーク)を使用して、簡易的な認証付きチャットツールを作成します。

想定する読者層

  • フロントエンドエンジニア

今回の想定する要件

  • リアルタイムチャットツールの作成
  • ユーザーの作成
  • ログインしていないときはログイン画面に遷移させる

今回作成画面

画面遷移図

作成画面.jpg

成果物

  • 相互にチャットのやりとりが可能となる

チャット画面.gif

作成したソース

https://github.com/Rtaaaaabo/amplify-chat-angular

Amplifyとは

Amplify とはサーバーレスなバックエンドをセットアップするための CLI、フロントエンドで利用できる UI コンポーネント、CI/CD やホスティングのためのコンソールを含む Web およびモバイルアプリ開発のためのフレームワーク

引用元:https://aws.amazon.com/jp/blogs/startup/techblog-3reasons-amplify/

ionicとは

Ionic Frameworkは、Webテクノロジー(HTML、CSS、JavaScript)を使って、高性能かつ高品質なモバイルとデスクトップアプリケーションをつくるためのオープンソースのUIフレームワーク

引用元:https://ionicframework.com/jp/docs/intro

Amplifyの準備

下記のドキュメントを参考する
https://docs.amplify.aws/start/getting-started/installation/q/integration/ionic

各バージョンを確認

  • Nodeのバージョン

10系以上であることを確認する

$ node -v
v12.7.0
  • npm のバージョン

5系以上であることを確認する

$ npm -v
6.14.5
  • Amplify CLIをインストールする

グローバルインストールを行う

$ npm install -g @aws-amplify/cli
  • AmplifyCLIをセットアップする
$ amplify configure
  • リージョンを選択する
Specify the AWS Region
? region:  
  eu-west-1 
  eu-west-2 
  eu-central-1 
❯ ap-northeast-1 (東京)
  ap-northeast-2 
  ap-southeast-1 
  ap-southeast-2 

  • ユーザー名を入力
- Specify the username of the new IAM user:
? user name:  taku-amplify
  • AWS Consoleが立ち上がる

ユーザーはアドミン権限で作成する

  • AccessKeyIdとSecretAccessKeyを入力
Enter the access key of the newly created user:
? accessKeyId:  xxxxxxxxxxxxxxxxx (ここはAccessKeyIdをを入力)
? secretAccessKey:  xxxxxxxxxxxxxxxxxxxxxx (ここは上記のSecretAccessKeyを入力)
  • ProjectNameを入力
This would update/create the AWS Profile in your local machine
? Profile Name:  taku-chat-test (各自のプロジェクトを入力)
  • 下記が出たら、成功
Successfully set up the new user.

ionicの準備

ionicプロジェクトの作成

$ npm install -g ionic  // グローバルインストール
$ ionic start (任意のプロジェクト名) blank --type=angular (今回はAngularで作成します。) // プロジェクトを作成する
$ cd (任意のプロジェクト名)
$ npm install // moduleのInstall
$ ionic serve

Amplifyにバックエンドを作成していく

  • ionic のプロジェクト配下で
$ amplify init
  • Project名を入力
? Enter a name for the project (testChat) 
  • 開発環境名を入力
? Enter a name for the environment dev
  • 作業するエディターの指定(今回はVS Code)
? Choose your default editor: 
❯ Visual Studio Code 
  Atom Editor 
  Sublime Text 
  IntelliJ IDEA 
  Vim (via Terminal, Mac OS only) 
  Emacs (via Terminal, Mac OS only) 
  None 
  • ビルド対象を選択(今回はionicでの開発なので、JacvaScript)
? Choose the type of app that you're building 
  android 
  ios 
❯ javascript
  • 開発するフレームワークを指定(ionicを指定)
? What javascript framework are you using 
  angular 
  ember 
❯ ionic 
  react 
  react-native 
  vue 
  none
  • 開発するルートディレクトリの指定(デフォルトでOK)
? Source Directory Path:  (src) 
  • ディストリビューションするディレクトリの指定(Amplifyを使用してホスティングも可能なので、ビルドしたファイルの場所の指定)
? Distribution Directory Path: (www) 
  • ビルドコマンドの指定
? Build Command:  (npm run-script build) 
  • ローカルデバッグするときのコマンドの指定
? Start Command: (ionic serve) 
  • AWS Profileを使用する
? Do you want to use an AWS profile? (Y/n) Y

? Please choose the profile you want to use 
  default 
❯ taku-chat-test  (先述で作成したProfileを指定)
  • 下記が出ていたら成功
Initialized your environment successfully.

Your project has been successfully initialized and connected to the cloud!

Some next steps:
"amplify status" will show you what you've added already and if it's locally configured or deployed
"amplify add <category>" will allow you to add features like user login or a backend API
"amplify push" will build all your local backend resources and provision it in the cloud
“amplify console” to open the Amplify Console and view your project status
"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud
  • Amplify Librariesをインストールする
$ npm install aws-amplify @aws-amplify/ui-angular --save

Ionic を Amplifyに接続していく

  • Ionic に GraphQLのAPIを接続する
$ amplify add api
  • 必要項目を入力していく
? Please select from one of the below mentioned services: GraphQL
? Provide API name: chat
? Choose the default authorization type for the API Amazon Cognito User Pool
Using service: Cognito, provided by: awscloudformation
 
 The current configured provider is Amazon Cognito. 
 
 Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections. 
 How do you want users to be able to sign in? Email
 Do you want to configure advanced settings? No, I am done.
Successfully added auth resource
? Do you want to configure advanced settings for the GraphQL API No, I am done.
? 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
Please edit the file in your editor: /Users/takunakagawa/dev/ionic/testChat/amplify/backend/api/chat/schema.graphql
? Press enter to continue 
  • 作成されたことを確認する
$ amplify status

Current Environment: dev

| Category | Resource name    | Operation | Provider plugin   |
| -------- | ---------------- | --------- | ----------------- |
| Auth     | testchatbf8c3b54 | Create    | awscloudformation |
| Api      | chat             | Create    | awscloudformation |
  • 設定をAmplifyに反映する
$ amplify push
✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

| Category | Resource name    | Operation | Provider plugin   |
| -------- | ---------------- | --------- | ----------------- |
| Auth     | testchatbf8c3b54 | Create    | awscloudformation |
| Api      | chat             | Create    | awscloudformation |
? Are you sure you want to continue? Yes

The following types do not have '@auth' enabled. Consider using @auth with @model
         - Message
Learn more about @auth here: https://aws-amplify.github.io/docs/cli-toolchain/graphql#auth 


GraphQL schema compiled successfully.

Edit your schema at /Users/takunakagawa/dev/ionic/testChat/amplify/backend/api/chat/schema.graphql or place .graphql files in a directory at /Users/takunakagawa/dev/ionic/testChat/amplify/backend/api/chat/schema
? 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
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
? Enter the file name for the generated code src/app/API.service.ts

(5分くらい反映までに待つ)

  • 反映されたか確認する
$ amplify status

Current Environment: dev

| Category | Resource name    | Operation | Provider plugin   |
| -------- | ---------------- | --------- | ----------------- |
| Auth     | testchatbf8c3b54 | No Change | awscloudformation |
| Api      | chat             | No Change | awscloudformation |

IonicにAmplifyの情報を記載していく

main.ts
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

// AmplifyのModuleをインポートする
import PubSub from '@aws-amplify/pubsub'; // 追加
import API from '@aws-amplify/api';       // 追加
import awsmobile from './aws-exports';    // 追加

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.log(err));

API.configure(awsmobile);              // 追加
PubSub.configure(awsmobile);           // 追加
  • Compile Packageに追加する
src/tsconfig.app.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": ["node"],        //追加
  },
  "files": [
    "src/main.ts",
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.ts",
    "src/**/*.d.ts"
  ],
  "exclude": [
    "src/**/*.spec.ts"
  ]
}

  • 下記を追加
srs/polyfiles.ts
(window as any).global = window;

(window as any).process = {
  env: { DEBUG: undefined },
};

最終的のTree

src/app
├── API.service.ts
├── app-routing.module.ts
├── app.component.html
├── app.component.scss
├── app.component.spec.ts
├── app.component.ts
├── app.module.ts
├── confirm-signup/
├── guard/
├── home/
├── interface/
├── login/
├── service/
└── signup/

実装

ログイン画面の作成

$ ionic g page login
login.page.html

<!-- ログイン画面のヘッダー -->
<ion-header>
  <ion-toolbar>
    <ion-title>ログイン</ion-title>
  </ion-toolbar>
</ion-header>
<!-- ↑ログイン画面のヘッダー -->

<ion-content>
  <form [formGroup]="loginForm">
    <ion-item>
      <ion-label position="floating">メールアドレス</ion-label>
      <ion-input formControlName="email"></ion-input>
    </ion-item>
    <ion-item>
      <ion-label position="floating">パスワード</ion-label>
      <ion-input type="password" formControlName="password"></ion-input>
    </ion-item>
  </form>
  <ion-button (click)="onLogin()" expand="block">ログイン</ion-button>
  <div (click)="onSignUp()" class="new-signup-member">
    <span>新規登録</span>
  </div>
</ion-content>

login.page.ts
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { FormGroup, FormControl, Validators } from '@angular/forms'; // FormはAngularのAPIを使用する
import { SessionService } from '../service/session.service'; // 後述で作成する

@Component({
  selector: 'app-login',
  templateUrl: './login.page.html',
  styleUrls: ['./login.page.scss'],
})
export class LoginPage {

  // Form Groupの作成
  loginForm = new FormGroup({
    email: new FormControl('', [Validators.required]),
    password: new FormControl('', [Validators.required]),
  });

  constructor(
    private router: Router,
    private sessionService: SessionService,
  ) { }


  // ログインボタンをクリック時に動作する
  onLogin() {
    const value = this.loginForm.value;
    // 後述で作成するSessionServiceのメソッドを記載する
    this.sessionService.signIn(value.email, value.password).subscribe((signInResult) => {
      this.router.navigate(['/home']); // Home画面へ繊維する
    });
  }

  // 新規登録ボタンクリック時に動作する
  onSignUp() {
    this.router.navigate(['/signup']);
  }

}

新規登録画面の作成

$ ionic g page signup
signup.page.html
<ion-header>
  <ion-toolbar>
    <ion-title>新規登録</ion-title>
    <ion-buttons slot="start">
      <ion-back-button text="" defaultHref="/login"></ion-back-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>
  <form [formGroup]="signupForm">
    <ion-item>
      <ion-label position="floating">ユーザー名</ion-label>
      <ion-input formControlName="userName"></ion-input>
    </ion-item>
    <ion-item>
      <ion-label position="floating">メールアドレス</ion-label>
      <ion-input formControlName="email"></ion-input>
    </ion-item>
    <ion-item>
      <ion-label position="floating">パスワード</ion-label>
      <ion-input type="password" formControlName="password"></ion-input>
    </ion-item>
  </form>
  <ion-button expand="block" (click)="onConfirmSignup()">新規登録</ion-button>
</ion-content>

signup.page.ts
import { Component } from '@angular/core';
import { Router, NavigationExtras } from '@angular/router';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { SessionService } from '../service/session.service';
import { tap } from 'rxjs/operators';

@Component({
  selector: 'app-signup',
  templateUrl: './signup.page.html',
  styleUrls: ['./signup.page.scss'],
})
export class SignupPage {
  // 新規登録Form Groupの作成
  signupForm = new FormGroup({
    userName: new FormControl('', [Validators.required]),
    email: new FormControl('', [Validators.email, Validators.required]),
    password: new FormControl('', [Validators.required]),
  });

  constructor(
    private router: Router,
    private sessionService: SessionService,
  ) { }

  // 新規登録時に確認画面に遷移させるメソッド
  onConfirmSignup() {
    const value = this.signupForm.value;
    const navigationExtra: NavigationExtras = {
      queryParams: {
        userName: value.userName
      }
    };
    this.sessionService.entryUserSignUp(value).pipe(
      tap(() => this.router.navigate(['/confirm-signup'], navigationExtra))
    ).subscribe();
  }
}

メールアドレスの確認画面

$ ionic g page confirm-signup
confirm-signup.page.html
<ion-header>
  <ion-toolbar>
    <ion-title>新規ユーザーの確認</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <form [formGroup]="confirmForm">
    <ion-item>
      <ion-item>
        <ion-label position="stacked">確認コード</ion-label>
        <ion-input required type="text" formControlName="confirmNumber">
        </ion-input>
      </ion-item>
    </ion-item>
    <ion-button type="submit" (click)="confirmSignUp()" expand="block"
      >確認</ion-button
    >
  </form>
</ion-content>

confirm-signup.page.ts
import { Component } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, Validators, FormControl } from '@angular/forms';
import { SessionService } from '../service/session.service';

@Component({
  selector: 'app-confirm-signup',
  templateUrl: './confirm-signup.page.html',
  styleUrls: ['./confirm-signup.page.scss'],
})
export class ConfirmSignupPage {

  // メールから受け取る番号の数字の FormGroupの作成
  confirmForm = new FormGroup({
    confirmNumber: new FormControl('', [Validators.required]),
  });

  userName: string;

  constructor(
    private sessionService: SessionService,
    private route: ActivatedRoute,
    private router: Router,
  ) {
    this.route.queryParams.subscribe((param) => {
      this.userName = param.userName;
    });
  }

  confirmSignUp() {
    const valueNumber = this.confirmForm.value.confirmNumber;
    this.sessionService.confirmSignup(this.userName, valueNumber).subscribe((result) => {
      const query = { queryParams: { result: 'Success' } };
      this.router.navigate(['/login'], query);
    });
  }

}

チャットルーム画面の作成

home.page.html

<ion-header [translucent]="true">
  <ion-toolbar>
    <ion-title>
      Chat on Amplify
    </ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="onSignOut()">
        <ion-icon name="log-out-outline"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content [fullscreen]="true">
  <ion-grid>
    <ion-row *ngFor="let msg of messages">
      <ion-col
        size="9"
        *ngIf="currentEmail !== msg.email"
        class="message other-message"
      >
        <!-- <b>{{msg.email}}</b><br /> -->
        <span> {{ msg.content }} </span><br />
        <div class="time" text-right>
          <br />
          <!-- Angular Date Pipe -->
          {{msg.createdAt | date:'yyyy/MM/dd hh:mm' }}
        </div>
      </ion-col>

      <ion-col
        offset="3"
        size="9"
        *ngIf="currentEmail === msg.email"
        class="message my-message"
      >
        <!-- <b>{{msg.email}}</b><br /> -->
        <span> {{ msg.content }} </span><br />
        <div class="time" text-right>
          <br />
          {{msg.createdAt | date:'yyyy/MM/dd hh:mm'}}
        </div>
      </ion-col>
    </ion-row>
  </ion-grid>
</ion-content>

<ion-footer>
  <ion-toolbar color="light">
    <ion-row align-items-center class="ion-no-padding">
      <ion-col size="10">
        <textarea
          autosize
          maxRows="4"
          [(ngModel)]="chatMessage"
          class="message-input"
        ></textarea>
      </ion-col>
      <ion-col size="2">
        <ion-button
          expand="block"
          fill="clear"
          color="primary"
          [disabled]="chatMessage === ''"
          class="msg-btn"
          (click)="sendChatMessage()"
        >
          <ion-icon name="send" slot="icon-only"></ion-icon>
        </ion-button>
      </ion-col>
    </ion-row>
  </ion-toolbar>
</ion-footer>

home.page.ts
import { Component, ViewChild, OnInit } from '@angular/core';
import { SessionService } from '../service/session.service';
import { IonContent, Platform } from '@ionic/angular';
import { APIService } from '../../app/API.service';
import { v4 as uuid } from 'uuid';
import { Message, responseCreateMessageListener } from '../interface/message';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage implements OnInit {

  @ViewChild(IonContent, { static: false }) content: IonContent;

  chatMessage;
  currentEmail: string;
  messages: Message[];   // 投稿されたメッセージを格納する変数

  constructor(
    private sessionService: SessionService,
    private apiService: APIService,
    private platform: Platform,
  ) {
    this.initializeApp();
  }

  // 初期読み込み
  ngOnInit() {
    this.sessionService.fetchCurrentUser().subscribe((email: string) => {
      this.currentEmail = email;
      this.apiService.ListMessages().then(data => {
        this.messages = data.items;
      });
    });
  }

 // ログアウトボタンのクリック時
  onSignOut() {
    this.sessionService.signout();
  }

  sendChatMessage() {
    const inputMessage = this.chatMessage;
    const contentMessage = {
      id: `${uuid()}`,
      email: this.currentEmail,
      content: inputMessage
    };
    this.apiService.CreateMessage(contentMessage).then();
  }

  initializeApp() {
    this.platform.ready().then(() => {
      this.apiService.OnCreateMessageListener.subscribe((evt: any) => {
        this.messages.push(evt.value.data.onCreateMessage);
      });
    });
  }
}
  • チャットのSCSSの体裁を整える
home.page.scss
#container {
  text-align: center;

  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
}

#container strong {
  font-size: 20px;
  line-height: 26px;
}

#container p {
  font-size: 16px;
  line-height: 22px;

  color: #8c8c8c;

  margin: 0;
}

#container a {
  text-decoration: none;
}

.todo-card-detail {
  margin: 5px 5px 5px;
}

.describe-todo {
  margin: 10px 0 0 0;
}

.message {
  padding: 10px;
  border-radius: 10px;
  margin-bottom: 4px;
  white-space: pre-wrap;
}

.other-message {
  background-color: var(--ion-color-tertiary);
  color: #fff;
}

.my-message {
  background-color: var(--ion-color-secondary);
  color: #fff;
}

.time {
  color: #dfdfdf;
  float: right;
  font-size: small;
}

.message-input {
  color: black;
  width: 100%;
  border: 1px solid var(--ion-color-medium);
  border-radius: 10px;
  background: #fff;
  resize: none;
  padding-left: 10px;
  padding-right: 10px;
}

.msg-btn {
  --padding-start: 0.5em;
  --padding-eng: 0.5em;
}

Serviceの作成

AmplifyのAPIと接続するService

$ ionic g service service/session
session.service.ts
import { Injectable } from '@angular/core';
import { Auth } from 'aws-amplify';
import { Observable, from, BehaviorSubject, of } from 'rxjs';
import { map, tap, catchError } from 'rxjs/operators';
import { Router } from '@angular/router';
import { InterfaceUser } from '../interface/interface-user';

@Injectable({
  providedIn: 'root'
})
export class SessionService {
  loggedIn: BehaviorSubject<boolean>;

  constructor(private router: Router) {
    this.loggedIn = new BehaviorSubject<boolean>(false);
  }

  // ユーザーがログインしているかしていないかをチェック
  isAuthenticated(): Observable<boolean> {
    return from(Auth.currentAuthenticatedUser()).pipe(
      map(() => {
        this.loggedIn.next(true);
        return true;
      }),
      catchError(() => {
        this.loggedIn.next(false);
        return of(false);
      })
    );
  }

  // 新規ユーザーの登録
  entryUserSignUp(value: InterfaceUser): Observable<any> {
    const username = value.userName;
    const email = value.email;
    const password = value.password;
    console.log(value);
    return from(Auth.signUp({
      username,
      password,
    }));
  }

  // ユーザーのログイン処理
  signIn(email, password): Observable<any> {
    return from(Auth.signIn(email, password)).pipe(
      tap(() => this.loggedIn.next(true))
    );
  }

  // チェック Confirm Number
  confirmSignup(userName, code): Observable<any> {
    return from(Auth.confirmSignUp(userName, code));
  }

  // SignOutのための実装
  signout() {
    from(Auth.signOut()).subscribe(() => {
      this.loggedIn.next(false);
      this.router.navigate(['/login']);
    });
  }

  // ログインしているユーザーの取得
  fetchCurrentUser(): Observable<string> {
    return from(Auth.currentAuthenticatedUser()).pipe(map((result) => result.attributes.email));
  }
}

Guardの実装

ユーザーがログインしていないときはLogin画面に遷移させる

$ ionic g guard auth
auth.guard.ts
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { SessionService } from '../service/session.service';
import { tap } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {

  constructor(private sessionService: SessionService, private router: Router) { }

  canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
    return this.sessionService.isAuthenticated().pipe(
      tap(loggedIn => {
        if (!loggedIn) {
          this.router.navigate(['/login']);
        }
      })
    );
  }
}

Interfaceの実装

ログイン、新規登録画面に必要となるInterface

$ ionic g interface interface/interface-user
interface-user.ts
export interface InterfaceUser {
    userName: string;
    email: string;
    password: string;
}

メッセージ投稿に必要となるInterface

message.ts
export interface Message {
    id: string;
    email: string;
    content: string;
    createdAt: string;
    updatedAt: string;
}

実行

$ ionic serve

最後に

感想

Amplifyを使用することで、サーバーサイドを考えないでチャットツールを一日で作成できる世の中になって、本当にすごいなぁって思った。
フロントエンドエンジニアならば、Amplifyを使用することで一個のサービスが作成できてしまうので、本当に使った方がいいと思った。
また、今回はGraphQLを使用したが、スキーマ設計さえできればそんなにGraphQLの知識も必要なく、ionic用にServiceもAmplifyで勝手に作成してくれるので本当に便利な世の中だな。って思った。

Discussion