4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

keycloak or React or NestJS

Last updated at Posted at 2021-08-08

keycloak or React or NestJS

この記事は「いのべこ夏休みアドベントカレンダー 2021-8-12 の記事となります。
あくまでホビーレベルの個人的趣味、技術探求なのでご参考までに・・・


:bulb: はじめに

記事タイトルが「keycloak or React or NestJS」となっており「or」で何れかを書こうかという感じでしたが、
全部絡めて書こうと思います。理由は実際の業務では、巷には技術情報があふれていて最新技術やベンダロック技術をやらない限り、
今の時代のエンジニアは組合せをいかに早くモノにするスキルが結構必要なんじゃないかなと思ってます。
この辺りの重要性やマインドは社内の発表会でも訴えた部分ではあります:point_up:

:syringe: 目的

上で述べた通りピンポイントでXXXやってみましたという感じではなく(それも重要ですが)
、各要素技術を組み合わせてより実践的(業務で作るレベル)な成果物の完成目指します。
訴えたい部分としては、自宅でホビーでここまで作るか!?という感じでなるべく妥協しないようにします。
お題は巷で話題!?の「接種予約システム」の一部を想定して作成します。
社内の若手が研修で予約者側の作成をしているので、私は自治体側の「接種予約一覧」を作成します。

:open_file_folder: 成果物

作成過程の説明がなが~いので成果物を先にご紹介します。実際の動作キャプチャです。
システムへログインして、接種予約一覧を表示(するだけ)です。あとログアウトできます:point_up:
finished-product.gif
keycloakを用いてフロントエンド、バックエンドの認証/認可を分けています。
認証が必要な場面でkeycloakへリダイレクトして、リソースアクセスに必要なトークンを発行してもらってます。

:tools: 適用技術について

狭義、広義で色々な技術を組み合わせて実現させてます。
実際の業務でもこれを使ってサクッと作れるレベルが求められます。(新技術でも習得時間は短く即戦力が求められます)

狭義の技術スキル

よくXXXができる人と兵隊を集める際に言われる部分 従来はもっとざっくりでJava,C#ができる人とかだったきがする)

  • フロントエンド技術:React、デザイン?(デザインはMaterialUIにお任せですが・・・:sweat:
  • バックエンド技術:Node.js、NestJS(Express)、TypeORM、DB設計、実装(幅広く)
  • 認証/認可技術:OAuth、OpenID Connect、keycloak
  • 言語:サーバーサイドjavascript、TypeScript

ここから下は、この成果物でサービス構築する際に必要なスキル

  • コンテナ技術: Docker(compose)、k8s
  • アーキテクチャ:色々ありますよね、正解はないですし一長一短なのであえて述べませんが
  • 基盤構築技術:クラウド、CI/CD基盤 ベース技術があれば応用が利くので何れかの経験でOKだと思います。
  • 運用/保守技術:概要を理解し実際に必要なツール導入(zabbix、fluentd)これもツールありきではないです。

広義の技術スキル(可視化できないスキル この辺は人に依存したポテンシャル部分)

  • 様々な引き出しをバックエンドとした、柔軟な設計能力(経験ともいう)
  • 既存の技術スキルを超えていくスキル(努力ともいう)
  • 何事にもあきらめず前向きな姿?(チャレンジ精神ともいう)
  • 仕事を楽しめるスキル(仕事とは何かを理解し没頭しすぎない)

ほぼ精神論ですね。この辺のスキルも非常に重要だとは思いますが・・・

今のご時世「多能工」が活躍できる機会も多いし求められている(DX、アジャイル、スクラムなどのキーワードで)
大規模なウォーターフォール的PJではピンポイント技術で仕事になりますが、
小規模な場合、「なんでも屋さん」が重宝されますよね
そして人月ビジネスの場合、「余計なことやるな」「いわれたこと(仕様)を実装すればいい」なんて感じです。
良かれと思って客先で色々やると、「余計な工数」として会社の利益(原価)悪化を招くということになります。
しかもバグを作りこんでいたり、行間を読めとかもうデスマーチ状態です。
あくまで決められた仕様を高品質かつ高生産性で作ることを追求されます。結構ストレスだったりします。

ふわっとしたビジネスイメージから、サクッと動くものを作ってスプリントを回して作りこむみたいなイメージで仕事できる人でしょうか?
お客様と一体になって上手く目標(ビジネス実現)に向かっていくのが本来の目的ですから。
自社の利益も重要ですが経験上結果は後からついてくると思います。(時間はかかります)
ただしスコープを決めるうえでもやりたいことに対するビジネスセンス(工数見積もり)は必要ですよね。

私がこうしてホビーで守秘義務に抵触しない範囲で開発をするのは、知識の定着に「手を動かすこと」が必要だからです。
よく課題や壁にぶち当たりピンポイントでホビーで開発したりしてます(もう仕事の境界がない・・・)
オラクルの旧Gold取得時なんて自宅のDB何回も壊してコマンドで復旧でニンマリなんて:relaxed:

:gear:作成するもの

以下の図の「予約確認」部分です。他にも業務は沢山ありますが・・・
image.png

ざっくりストーリー

  1. 自治体職員はPCブラウザでシステムのWebページを開く
  2. ログイン画面から自身のアカウント情報を使いシステムへログインする
  3. 接種予約一覧から予約、接種状況を確認する
  4. システムからログアウトする

軽く設計

  • 業務設計

上記のストーリーから必要な機能は以下の通り

  1. ログイン、ログアウト機能
  2. 接種予約一覧表示機能

接種予約一覧表示機能のインプット

接種券のサンプル情報から推測します。
恐らく発送時に住基の情報と紐づけてはいると思いますね。細かいことは分からないですが・・・
予約機能側で券番号、氏名、予約日時、接種会場を確定することにします。
制約としては1回目の接種が完了したら、2回目予約済になるということでしょうか?

  • 画面設計

:desktop:トップ画面
2 (1).png
:desktop:ログイン画面(keycloak)
image.png
:desktop:予約一覧
2-2-画面イメージ(予約一覧) (1).png

  • API設計
    上記の画面「予約一覧」で必要なAPIを抽出します。
    あまり細かく分ける必要もないかなと思いますので、予約情報を取得するAPIとして設計します。
    本来であればAPI設計としては接種会場、接種対象者は分けたほうがいい!!となると思います。
    時間の関係上1APIで全部のリソースを取得してしまいます。(この辺はパフォーマンスとのトレードオフかもしれないですね)

GET inoculation/reservations/:id 予約取得API
GET inoculation/persons/:id 接種対象者取得API
GET inoculation/venues/:id 接種会場取得API

[概要]
〇〇市の接種予約済一覧を取得する
[エンドポイント]
GET /api/v1/inoculation/reservations
[パラメータ]
なし
[レスポンス]
{
id: number;
ticketNumber: string;
fullName: string;
...
}
[ステータス]
200 OK
403 Forbidden
404 Notfound

  • データベース設計

ここまでできればデータベース設計は問題ないと思います。
本当は1エンティティでもいいのですが、属性の違うものは分離しました。(人、場所)

image.png

ちょっと図が変な感じもしますが、以下3エンティティになります。
接種予約 : 接種予約対象者 (1:1)
接種予約 : 接種会場 (n:1)

※厳密には0~を考慮する必要がありますが、今回はデータありきなので割愛します。
この辺はちゃんと定義しないと、結合時にnull考慮不足でエラーになったりするので重要ですが・・・
本当はCRUD等でディスカッションしたりしてインターフェースやエンティティを決めていくのがいいですね。

エンティティ名 説明
inoculation_reservation  接種予約
inoculation_target_person 接種予約対象者
inoculation_venue 接種会場

ここを深堀すると中々大変ですよね。特に予約側。とてもアドベントカレンダーレベルではなくなる。
接種会場のキャパや時間枠、予約対象者の属性・・・考えること一杯ですが全部割愛します!

この辺を妄想して色々考えるスキルを「業務機能設計スキル」といったりします。
やりたいことに対し制約など抽出し機能設計に落とし込むことです。
このシステムで言えば、会場の枠以上に予約できたらダメですし色々制約かけないと機能しないですよね。

環境整備

実装を始める前に環境を整備します。以下環境を前提に作成を進めます。

  • コードエディタ:VSCode ...最近はこれだけで生活できそうな気がするぐらい使っている
  • レポジトリ管理:モノレポlerna バージョン戦略は「Fixed」
  • React:create-react-appでひな形作成
  • NestJS:NestJSのCLIを使ってひな形作成
  • keycloak:docker-composeで起動
  • DB:postgres 上記のcompose内で公式イメージからPullして起動

いよいよ実装

作るものは分かっているのでどこからはじめてもいいのですが、教科書的にDBに近い部分から作ります。

バックエンド(API)の実装
  • TypeORM
    NestJSではTypeORMというORマッパをサポートしているので、公式サイトに従い準備します。
    今回はPostgresでいきます。(実はsqliteを試して少し挫折したので・・・:confounded:
    実際にはDBの接続先は環境毎に変わるのでnode-config
    dotenvNestJSのお作法などを利用して環境依存は排除すべきです。今回は.envを使ってますなくても||でデフォルト適用となります

データベース接続部分

app.module.ts
...
@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: process.env.DB_HOST || 'localhost',
      port: Number(process.env.DB_PORT) || 5432,
      username: process.env.DB_USER || 'postgres',
      password: process.env.DB_PASS || 'password',
      database: process.env.DB_DATA || 'postgres',
      synchronize: true,
      logging: false,
      entities: getMetadataArgsStorage().tables.map((tbl) => tbl.target),
      migrations: [__dirname + '**/entities/migrations/*.{js,ts}'],
      cli: {
        migrationsDir: 'src/entities/migrations',
      },
    }),
    TypeOrmModule.forFeature([
      InoculationReservation,
      InoculationTargetPerson,
      InoculationVenue,
    ]),
...

上記のようにモジュール定義しておけばアプリケーション起動時にコネクションを張ってくれます。
接続できないとアプリケーションが起動しません(リトライもデフォルトでやってくれます)

DBアクセス実装
Repository patternをサポートしており、公式サイトに倣ってサービスクラスへ実装します。
業務ではDDDで実装していてもう少しレイヤを分けていますがこれはまたの機会ということで

inoculation.reservation.entity.ts
@Entity()
export class InoculationReservation {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'varchar', length: 10, unique: true })
  ticketNumber: string;

  @OneToOne(() => InoculationTargetPerson)
  @JoinColumn()
  inoculationTargetPerson: InoculationTargetPerson;

  @ManyToOne(() => InoculationVenue)
  @JoinColumn()
  inoculationVenue: InoculationVenue;

  @Column()
  firstTimeDate: Date;

  @Column()
  firstTimeStatus: InoculationReservationStatus;

全部載せませんが、上記のように3エンティティ定義します。
定義できてしまえばDDL不要です。TypeORMが良しなにテーブルをPostgresへ作成してくれます。
(データ検索もSQL開発不要です。上記のデコレータ(@OneToOne,@ManyToOne)等でリレーション定義します。

サービスクラス
ほとんど実装ありません。データを検索して少しコンバートする程度です。

app.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as moment from 'moment-timezone';
import { Repository } from 'typeorm';
import { Reservation } from './app.interface';
import { InoculationReservation } from './entities/inoculation.reservation.entity';

@Injectable()
export class AppService {
  constructor(
    @InjectRepository(InoculationReservation)
    private inoculationReservationRepository: Repository<InoculationReservation>,
  ) {}

  async getInoculationReservations(): Promise<Reservation[]> {
    const result = await this.inoculationReservationRepository.find({
      relations: ['inoculationTargetPerson', 'inoculationVenue'],
    });

    const reservations = result.map((item) => {
      const reservation: Reservation = {
        id: item.id,
        ticketNumber: item.ticketNumber,
        firstTimeDate: item.firstTimeDate
          ? moment(item.firstTimeDate)
              .tz('Asia/Tokyo')
              .format('YYYY/MM/DD HH:mm')
          : '',
        firstTimeStatus: item.firstTimeStatus,
        secondTimeDate: item.secondTimeDate
          ? moment(item.secondTimeDate)
              .tz('Asia/Tokyo')
              .format('YYYY/MM/DD HH:mm')
          : '',
        secondTimeStatus: item.secondTimeStatus || '',
        note: item.note || '',
        fullName:
          item.inoculationTargetPerson.firstName +
          ' ' +
          item.inoculationTargetPerson.lastName,
        venue: item.inoculationVenue.name,
      };

      return reservation;
    });

    return reservations;
  }
}

@InjectRepositoryデコレータで接種予約リポジトリをDIします。
そのリポジトリを使ってfind()ですべてのレコードを抽出しています。
引数にrelationsを指定すればデフォルトではleft outer joinしてくれます。(左外部結合)
データはあるので外部結合でなくてもいいのですが・・・
予約日時はDB上UTCで保持しているので(JSTは+9)momentを使ってタイムゾーンを指定してフォーマットかけてます。
あとは氏名は(普通)姓名等分けて保持するので結合して返します。

コントローラークラス

app.controler.ts

import { Controller, Get } from '@nestjs/common';
import { AuthenticatedUser, Resource, Scopes } from 'nest-keycloak-connect';
import { Reservation } from './app.interface';
import { AppService } from './app.service';

@Controller('inoculation')
@Resource('inoculation-reservation-app-api')
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('reservations')
  @Scopes('')
  async getInoculationReservations(
    @AuthenticatedUser()
    user: any,
  ): Promise<Reservation[]> {
    if (user) {
      console.log(`access from=>${user.preferred_username}`);
    }
    return await this.appService.getInoculationReservations();
  }
}

色々デコレータで修飾されていますが、NestJS自体色々デコレータで修飾して振舞い決めるフレームワークなので・・・
少し解説します。

@Controller('inoculation') コントローラーを表すデコレータ。エンドポイントURLの一部を渡します。
@Resource('inoculation-reservation-app-api') これはkeycloakのリソース(クライアント)を指定します。
keycloak設定については後程解説します。
@Get('reservations') GETメソッドであることを表します。エンドポイントURLの一部を渡します。
@Scopes('')使ってませんが指定しないと何故かエラーになるので指定します。これもkeycloak関連です。
@AuthenticatedUser() これはリクエストヘッダのbearerトークンに含まれているJWT内のユーザ情報を取得できます。
使ってませんが、これで誰がAPIアクセスしてきたかわかります。
このデコレータで認証が必要なAPIとなり、認証がされていないアクセスでは403が返されます。
認証が不要なパブリックなAPIはデコレータ「@Public()」をつけます。
ロールなどの制御やメソッド(GET、POST、DELETEなど)によってもデコレータで細かい制御が可能。

publicなAPIの例
@Get()
  @Public() // Can also use `@Unprotected`
  async findAll() {
    return await this.service.findAll();
  }

これだけでAPI側の実装は終わりです。
テストコード等も作成していますが、割愛します。

最後にkeycloak設定です。
今回はNestJSのモジュールライブラリnest-keycloak-connectを利用します。簡単に言ってしまうとnode.js用のkeycloakアダブタのラッパーになります。
上述のコントローラークラスをデコレータでセキュアなAPIにできるのもこのラッパーのおかげです。
仕組みとしてはNestJSのGuardsの仕組みを使っています。
ベースの部分はExpressのミドルウェアを使ってkeycloakを入れ込んでいますね。

app.module.ts

@Module({
  imports: [
    ...
    KeycloakConnectModule.register({
      realm: 'inoculation-reservation-app',
      realmPublicKey:
        'レルム公開鍵',
      authServerUrl: 'http://localhost:8080/auth',
      resource: 'inoculation-reservation-app-api',
      'public-client': true,
      secret: 'クライアントシークレット',
      cookieKey: 'KEYCLOAK_JWT',
      logLevels: ['log', 'error', 'warn', 'debug', 'verbose'],
      useNestLogger: true,
      bearerOnly: true,
      policyEnforcement: PolicyEnforcementMode.ENFORCING,
      tokenValidation: TokenValidation.OFFLINE,
    }),
  ],
  providers: [
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
...

こんな感じで先ほどDB接続設定を入れたモジュール定義にkeycloak設定も追加します。
キーなどははkeycloakのadmin cosoleを設定した上で設定する必要があります。
階層はこんな感じです。
image.png

フロントエンド(React)の実装

実際にはバックエンド単体でユニットテスト、e2eテストを実施してAPIが仕様通りに正しく動作するか確認したうえでOKとしますが、
(実装していますが・・・)その辺のテクニックや技術は後日記事を別に書きます。
あとは実際にサーバー上でビルド&デプロイする仕組みも必要ですし、テストを自動化、可視化する必要もあります。
外部公開するのであればセキュリティー対策や第三者の監査なども必要!!
この辺が実際の業務で培った技術がたっぷり詰まっていて他SEと差別化できる部分でもあります。
(色々Quita見ても俯瞰して書いてある記事を見たことがないです 皆さん出し惜しみしますよね)

これくらいにして画面を作りましょう!

私ははっきりいって画面側(特にデザイン)は苦手です。センスがないのです
あまり凝ったことができないので、出来合いのモノ(フレームワーク)を利用します。
今回はMATERIAL-UIを利用します。
様々なコンポーネントが利用でき、Google提唱のマテリアルデザインが実現できます。
テーマ変更やカスタマイズも簡単で、今回は使っていませんがReact Hook Form等も対応しているのでBootStrap等と並んで人気のあるコンポーネントです。

フロント側のkeycloakについてはreact-keycloak/webを利用します。
解説し忘れましたが、フロント、バックエンド共に認証をkeycloakでやって、SSOを実現する感じです。

ログイン周り

index.tsx
import { AuthClientError, AuthClientEvent } from "@react-keycloak/core";
import { ReactKeycloakProvider } from "@react-keycloak/web";
import { KeycloakProfile } from "keycloak-js";
import * as React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import "./index.css";
import keycloak from "./keycloak";
import reportWebVitals from "./reportWebVitals";

export interface UserProfile extends KeycloakProfile {}

class UserProfileImpl implements UserProfile {
  constructor(profile: KeycloakProfile) {
    Object.assign(this, profile);
  }
}

export const UserProfileContext = React.createContext<UserProfile | undefined>(
  undefined
);

const Root = () => {
  const [userProfile, setUserProfile] = React.useState<
    UserProfile | undefined
  >();

  const eventLogger = async (
    event: AuthClientEvent,
    error: AuthClientError | undefined
  ) => {
    console.log("onKeycloakEvent", event, error);
    if (event === "onAuthSuccess" && !userProfile) {
      const profile = await keycloak.loadUserProfile();
      setUserProfile(new UserProfileImpl(profile));
    }
  };

  const tokenLogger = (tokens: unknown) => {
    console.log("onKeycloakTokens", tokens);
  };

  return (
    <ReactKeycloakProvider
      authClient={keycloak}
      onEvent={eventLogger}
      onTokens={tokenLogger}
    >
      <UserProfileContext.Provider value={userProfile}>
        <App />
      </UserProfileContext.Provider>
    </ReactKeycloakProvider>
  );
};

ReactDOM.render(
  <React.StrictMode>
    <Root />
  </React.StrictMode>,
  document.getElementById("root")
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

あまり分割していないので見づらいですが、ReactKeycloakProviderやUserProfileContextで括って、
子コンポーネントで利用できるようにしてます。

App.tsx一部
...
const App = () => {
  const { initialized } = useKeycloak();

  if (!initialized) {
    return (
      <Grid
        container
        spacing={0}
        direction="column"
        alignItems="center"
        justifyContent="center"
        style={{ minHeight: "100vh" }}
      >
        <Grid item xs={3}>
          <Grid>
            <CircularProgress size={80} thickness={4} />
          </Grid>
          <Grid>Loading...</Grid>
        </Grid>
      </Grid>
    );
  }

  return (
    <ThemeProvider theme={theme}>
      <BrowserRouter>
        <MenuAppBar />
        <Switch>
          <PrivateRoute path="/list" component={InoculationReservationList} />
          <Route path="/login" component={Login} />
          <Redirect from="/" to="/list" />
        </Switch>
      </BrowserRouter>
    </ThemeProvider>
  );
};

export default App;

PrivateRouteで保護するコンポーネントを定義します。
今回は接種一覧コンポーネントを保護したいのでパスを/listとして定義します。
パスが/loginの場合はログインコンポーネントを表示します。
keycloakはフックで提供されているので、useKeycloak()で呼び出します。
初期化に時間がかかるので、スピナ表示で初期化完了を待ちます。

Login.tsx

import { faSyringe } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Grid, makeStyles } from "@material-ui/core";
import LockOpenIcon from "@material-ui/icons/LockOpen";
import { useKeycloak } from "@react-keycloak/web";
import * as React from "react";
import { useCallback } from "react";
import { Redirect, useLocation } from "react-router-dom";

const useStyles = makeStyles((theme) => ({
  button: {
    margin: theme.spacing(1),
  },
}));

const Login = () => {
  const classes = useStyles();
  const { keycloak } = useKeycloak();
  const location = useLocation<{ [key: string]: unknown }>();

  const currentLocationState = location.state || {
    from: { pathname: "/list" },
  };

  const login = useCallback(() => {
    keycloak?.login();
  }, [keycloak]);

  // if (keycloak?.authenticated && keycloak?.hasRealmRole("web-access")) {
  if (keycloak?.authenticated) {
    return <Redirect to={currentLocationState?.from as string} />;
  }

  return (
    <Grid
      container
      direction="column"
      justifyContent="center"
      alignItems="center"
      spacing={5}
    >
      <Grid item></Grid>
      <Grid item>
        <h2>
          {" "}
          <FontAwesomeIcon icon={faSyringe} size="2x" />
          接種予約 advent-calendar-2021 ログイン
        </h2>
      </Grid>
      <Grid item>
        <Button
          onClick={login}
          variant="contained"
          color="primary"
          size="large"
          className={classes.button}
          startIcon={<LockOpenIcon />}
        >
          Login
        </Button>
      </Grid>
      <Grid item></Grid>
    </Grid>
  );
};

export default Login;

ログインコンポーネントではボタン押下でkeycloak?.login();を呼び出し、keycloakの認証画面へリダイレクトさせます。
認証後リダイレクトされ認証されている場合は/listへリダイレクトします。
あとmaterial-uiでは注射器のアイコンがなかったのでFontAwesomeIcon を使ってます。

image.png
こんな感じでトップ画面と認証回りは終わりです。
ナビゲーション部分は認証している/していないによって表示を変えています。

  • 認証情報なし
    image.png

  • 認証情報あり ユーザアイコン+ユーザ名を表示しサブメニュー(ログアウトなど)を表示
    image.png
    ユーザ名等はkeycloak側から得られるトークンに含まれている情報を表示しています。

一覧表示

特に凝っていないのでTableコンポーネントを使ってaxiosで取得したデータを表示しています。

App.tsx一部

const InoculationReservationList: React.FC = () => {
  const classes = useStyles();
  const [reservationList, setReservationList] = React.useState<Reservation[]>(
    []
  );
  const axiosInstance = useAxios("");

  useEffect(() => {
    const fetchData = async () => {
      const response = await axiosInstance?.current?.get(
        "/api/v1/inoculation/reservations"
      );
      setReservationList(response?.data);
    };

    fetchData();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <TableContainer component={Paper}>
      <Table className={classes.table} aria-label="customized table">
        <TableHead>
          <TableRow>
            <StyledTableCell>ID</StyledTableCell>
            <StyledTableCell align="right">接種券番号</StyledTableCell>
            <StyledTableCell align="right">氏名</StyledTableCell>
            <StyledTableCell align="right">会場</StyledTableCell>
            <StyledTableCell align="right">予約日時(1回目)</StyledTableCell>
            <StyledTableCell align="right">状況</StyledTableCell>
            <StyledTableCell align="right">予約日時(2回目)</StyledTableCell>
            <StyledTableCell align="right">状況</StyledTableCell>
            <StyledTableCell align="right">備考</StyledTableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {reservationList.map((row) => (
            <StyledTableRow key={row.id}>
              <StyledTableCell component="th" scope="row">
                {row.id}
              </StyledTableCell>
              <StyledTableCell component="th" scope="row">
                {row.ticketNumber}
              </StyledTableCell>
              <StyledTableCell component="th" scope="row">
                {row.fullName}
              </StyledTableCell>
              <StyledTableCell align="right">{row.venue}</StyledTableCell>
              <StyledTableCell align="right">
                {row.firstTimeDate}
              </StyledTableCell>
              <StyledTableCell align="right">
                {row.firstTimeStatus === "reserved" ? (
                  <Chip label="予約済" color="primary" />
                ) : row.firstTimeStatus === "inoculated" ? (
                  <Chip
                    label="接種済"
                    color="secondary"
                    disabled
                    deleteIcon={<DoneIcon />}
                  />
                ) : (
                  <span>&nbsp;</span>
                )}
              </StyledTableCell>
              <StyledTableCell align="right">
                {row.secondTimeDate}
              </StyledTableCell>
              <StyledTableCell align="right">
                {row.secondTimeStatus === "reserved" ? (
                  <Chip label="予約済" color="primary" />
                ) : row.secondTimeStatus === "inoculated" ? (
                  <Chip
                    label="接種済"
                    color="secondary"
                    disabled
                    deleteIcon={<DoneIcon />}
                  />
                ) : (
                  <span>&nbsp;</span>
                )}
              </StyledTableCell>
              <StyledTableCell align="right">{row.note}</StyledTableCell>
            </StyledTableRow>
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  );
};


hooks.ts

import { useKeycloak } from "@react-keycloak/web";
import axios, { AxiosInstance } from "axios";
import { useEffect, useRef } from "react";

export const useAxios = (baseURL: string) => {
  const axiosInstance = useRef<AxiosInstance>();
  const { keycloak, initialized } = useKeycloak();
  const kcToken = keycloak?.token ?? "";

  useEffect(() => {
    axiosInstance.current = axios.create({
      baseURL,
      headers: {
        Authorization: initialized ? `Bearer ${kcToken}` : undefined,
      },
    });

    return () => {
      axiosInstance.current = undefined;
    };
  }, [baseURL, initialized, kcToken]);

  return axiosInstance;
};

特筆するところはないですが、axiosに関してはカスタムフックを作成し、
APIアクセスに必要なヘッダ情報(Bearerトークン)を設定しています。(共通部品とか言ったりします?)
本当はもっと例外ハンドリングモック等axiosも奥が深いので色々できます。今回はaxiosインスタンス生成部分だけ切り出しました。
~~Allow-Originはあくまで開発用です。実際には適切なものを設定したりします。~~~
注)ここでの記述は不要でした。開発用にproxyを設定するため、axiosで設定する必要はありません。

接種予約状態についてはChipを使って見やすく表示しています。接種済の色がセンスないですが・・・
image.png

keycloak設定

ざっくりですが実装を説明したので、keycloak設定します。
docker-composeで起動させて、リバプロ(nginx)のアドレスへアクセスし、管理者でログインします。

余談ですが、実際の業務では、VM上やクラウド上に構築することになりネットワーク的な知識も多少必要で各サーバー間の疎通が重要ですが、
スタンドアロンで開発用に起動させます。途中にAPFWがいたり、名前解決できない等keycloakアダプタがトークン取れないとか
色々トラブルあります。

レルム作成
image.png

クライアント作成
image.png

今回は細かい制御してませんが、クライアントはwebとapiそれぞれ作成しました。

ユーザ作成
image.png

色々細かい設定ができるのですが、とりあえず一番簡単な設定で作成しました。
例えばパスワードテンポラリやメールアドレス検証、有効期間など
普通にアカウント管理する上で必要十分な機能を備えていますし、認証に関わる部分なので、独自実装だとセキュリティー的に不安ですよね
あとはAD連携できたり、アトリビュートを追加してJWTに含めたり
この辺でもカスタマイズを紹介してますね。時間があればチャレンジしてみたいです

これで使えるようになりました。完成です!:ribbon:
ホビーレベルではやらない(必要ない)細かいテクニックを少しだけご紹介します。

  • 開発用初期データ投入
    本番ではやりませんが開発時は初期データを自由に操りたいですよね?毎回データ投入するのはめんどくさい
    そこでtypeormのマイグレーション機能を使って初期データを起動時に投入します。
main.ts
const execMigration = async () => {
  await getConnection().query('delete from migrations');
  const result = await getConnection().runMigrations();
  console.log('run migrations is done');
  console.table(result);
};
1622117028739-InitData.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
import { InoculationReservation } from '../inoculation.reservation.entity';
import { InoculationTargetPerson } from '../inoculation.target.person.entity';
import { InoculationVenue } from '../inoculation.venue.entity';

export class InitData1622117028739 implements MigrationInterface {
  name = 'InitData1622117028739';

  async up(queryRunner: QueryRunner): Promise<void> {
    await this.down(queryRunner);

    const venues = await queryRunner.manager.save([
      new InoculationVenue('〇〇体育館', 'test'),
      new InoculationVenue('△△市民センター', 'test'),
      new InoculationVenue('◇◇公民館', 'test'),
    ]);

    const persons = await queryRunner.manager.save([
      new InoculationTargetPerson(
        '接種',
        '太郎',
        'taro.sessyu@test.com',
        'test',
      ),
      new InoculationTargetPerson(
        '接種',
        '花子',
        'hanako.sessyu@test.com',
        'test',
      ),
      new InoculationTargetPerson(
        '接種',
        '次郎',
        'jiro.sessyu@test.com',
        'test',
      ),
    ]);

    const getVenue = (name: string): InoculationVenue | undefined => {
      return venues.find((venue) => venue.name === name);
    };

    const getPerson = (email: string): InoculationTargetPerson | undefined => {
      return persons.find((person) => person.email === email);
    };

    await queryRunner.manager.save([
      new InoculationReservation(
        '0000000001',
        getPerson('taro.sessyu@test.com'),
        getVenue('〇〇体育館'),
        new Date('2021/07/01 15:00'),
        'inoculated',
        new Date('2021/07/14 15:00'),
        'reserved',
        '1回目済、2回目未',
        'test',
      ),
      new InoculationReservation(
        '0000000002',
        getPerson('hanako.sessyu@test.com'),
        getVenue('△△市民センター'),
        new Date('2021/07/02 14:00'),
        'reserved',
        null,
        null,
        '1回目未、2回目未',
        'test',
      ),
    ]);
  }

  async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.manager
      .createQueryBuilder()
      .delete()
      .from(InoculationReservation)
      .execute();

    await queryRunner.manager
      .createQueryBuilder()
      .delete()
      .from(InoculationVenue)
      .execute();

    await queryRunner.manager
      .createQueryBuilder()
      .delete()
      .from(InoculationTargetPerson)
      .execute();
  }
}

一旦マイグレーションテーブルのレコードを削除しrunMigration()でマイグレーション実行。
マイグレーションファイルに初期データを記述すれば起動時に投入できます。
またawait this.down(queryRunner);で
一旦反対処理(データ削除)を行っていてこれはpostgresのデータを永続化しているために毎回クリアしています。
シーケンスが進んでしまうのでシーケンス戻すなども必要かもしれないですし、データが多いと起動に時間がかかるので、
DBダンプから投入するなどソリューションは色々あります。この辺も実業務では工夫して提案し構築する部分です。

  • nodeプロセスkill
    ほんと細かいことですが、VSCode(gitbash)から起動停止を繰り返すとnodeのプロセスが残ってしまっていて、
    ポートが開放されず起動できないケースがあります。
    強引ですが、以下スクリプトでnodeのプロセスをクリーンアップしてしまいます。

image.png

  • プロキシ設定
    スタンドアロンで開発する場合、React側(localhost:3001)、API側(localhost:3000)の場合、同一オリジン制限に引っかかり、
    ブラウザからAPIへアクセスができません。
    http-proxy-middlewareを利用すると便利です。
setupProxy.ts
import { createProxyMiddleware } from "http-proxy-middleware";

module.exports = function (app: any) {
  app.use(
    "/auth/*",
    createProxyMiddleware({
      target: "http://localhost:8080",
      changeOrigin: true,
    })
  );
  app.use(
    "/api/v1/*",
    createProxyMiddleware({
      target: "http://localhost:3000",
      changeOrigin: true,
    })
  );
};

こんな感じで作成しておけばReactからのAPIやkeycloakへのアクセスをプロキシしてくれます。
changeOrigin:trueで良しなにやってくれていると思います。
これで問題なくアクセスできました。

:gear:最後に

簡単でしょう?というつもりは全くございません。すんごく行間ありますし、
ご紹介していない技術を色々盛り込んでますしこれではまだまだ実業務では使えないです・・・
アドベントカレンダーとしては中々ヘビーな量だと思います。(正直1日で実装できなかった・・・)
記事書きながら賞味2~3日で一応完成。でも実際の業務ではこれよりもっと大変ですし、
プレッシャーの中でモノを作らないといけませんし、もっと色々考えて考えて試して試して・・・作ります!作りこみます!!

経験があるからアドベントカレンダーネタとして比較的簡単そうに実装できます。
未経験、未踏技術なら大変ですしおそらく途中挫折します。
若手の予約機能は2週間掛かったとも聞いています。(0.5人月)それでも未完:sweat:
でも向上心、探求心があればきっと糧にはなっているはず!頑張ってほしいです!!
(私も若手に負けず日々探求して年齢を経験でカバーしています)

今の時代ある程度型にハマればノン〇〇でもできるとは思いますが・・・
我々はプログラミングできますが、工作機械もプログラミングで動きます(CNC)
でも我々では作れませんよね?なぜか??加工技術を持っていないからです。
いくらプログラムで工具を制御できても、どれくらいの速度とか素材に応じた最適解などは経験から学ぶもの。
何が言いたいかというと、どんなに機械化(ノン〇〇)が進んでも、
こういった「経験や人の感性でやる仕事」は人がやることだと思います。
特に何もないところから形にしていく過程や沢山のナレッジから様々なライブラリを利用してサクッと作り、サクッと動かす
そしてひたすら妥協しないで作りこむ。ダメな部分は後からでも直す。
私も一昔前はこんなサクッと開発できるとは思ってもいませんでした。
今求められているものはここではなく、この先なんですね:muscle:

気が向いたらGitにでもPushしておきます。
あとどこかのクラウドへデプロイもしたい!
もう少し機能実装もしたいけど仕事じゃないし・・・

~おしまい~

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?