TypeScript + Express on Dockerで簡単なRestAPI作成とテストを行う。

2024/05/01に公開

本記事より今後の記事は、である調からですます調で執筆していきます。
自分がである調を使ってるのがなんか偉そうで嫌だったためです笑

FlutterでAPIクライアントを実装するための下準備として、簡単に任意の一覧データを返す
RestAPIが必要になったので、自作してみることにしました。Expressは初見です。

環境構築

node, npmのインストールはせず、Docker内の環境構築で完結させます。

前提条件

  • Windows 11環境
  • Docker desktop for Windows(Docker Engine v25.0.2)

ファイル構成とDocker系ファイルの設定

アプリケーション自体を内包します。

ファイル系の設定は無難に行います。

Dockerfile
FROM node:20.11-alpine
WORKDIR /usr/src/app #コンテナ上のワーキングディレクトリを指定
docker-compose.yml
services:
  express-api: #image名
    container_name: express-api-container
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ./simpleAPI:/usr/src/app # ローカルをコンテナ内にマウント
    command: sh -c "npm start"
    ports:
      - "3000:3000"
    stdin_open: true 

コマンド

express_api ディレクトリ直下前提で記載していきます。
コンテナをビルド

docker-compose build 

コンテナ内bashに入ってから、ExpressとTypeScript周りの設定をインストールします。

docker-compose run --rm express-api sh

-D は --save-devの略。開発環境で使うときはオプションで指定します。

# npmプロジェクトの初期化とExpress導入
npm init && npm  install express
# TypeScriptで各種リソースを使うための依存関係導入
npm install -D typescript ts-node @types/node
npm install -D @types/express
# Node.jsのTypeScriptサポートのためにtsconfig.jsonが必要なので生成します。
npx tsc --init

Dockerコマンドの補足

以下どちらでもコンテナ上のbashに入れますし、依存関係もどちらでもインストール出来ます。

docker-compose exec -it <image名> sh 
docker-compose run --rm <image名> sh

違い

  • run --rm はコンテナを作って処理を流してから削除されるので
    依存関係を入れる時、単純なテストのように一時的に処理する場合などに適している。

  • exec は既存の起動中コンテナに対して処理をする。
    実行中の環境下でデバッグ、テストを行いたい場合などに適している。

※今回後で行うテストは実行中のAPIサーバにリクエストを送るため、docker-compose up後に
execのほうでbashに入ります。runで入った場合、サーバ起動、ポート割当はされず
新しくAPIのコンテナのみが立ち上がるのでfetchが失敗します。
(より詳細ご存知のかた、コメントでご教示願います。)

設定ファイルの書き換え

ts-nodeで実行時に読み込むTSファイルを設定します。
testがjestになってますが、テスト環境構築時に説明しますので一度おいておきます。

package.json
"scripts": {
    "test": "jest",
    "start": "ts-node src/index.ts"
},

index.tsを用意

simpleAPI/src/に移動し、index.tsを作成します。

index.ts
import express, { Request, Response } from "express";

const app = express();
const port = 3000;

app.get("/", (req: Request, res: Response) => {
  res.send("Hello World!");
});

// port開放
app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

動作確認

docker-composeで立ち上げ

docker-compose up (-d(background option)) 

http://localhost:3000/ にアクセス。
画面にHello World!と表示されたら構築は完了です。

RestAPIの作成

ニセのWantedlyの募集みたいなデータを返すAPIを作ります。

APIの機能

  • 一覧表示
  • キーワード検索(募集タイトルと業務内容のキーワードを拾って募集検索)
  • 404時は404レスポンス

実装

ソースコードのディレクトリ構成

データのType定義とリストデータの作成

募集に関する情報としての型定義をして、値を安全に取り扱います。

recruitinfo.ts
type RecruitInfo = {
    id: number,
    needs_title: string,
    work_context: string,
    user_id: string,
    created_at: Date,
    updated_at: Date | null,
}

export default RecruitInfo;

今回は簡易的なAPI作成のため。DB連携は行わずにリストデータをコードで定義しておきます。
chatGPTで10件ほど生成したので、GitHubリポジトリにて確認してみてください。

recruitlist.ts
const RECRUIT_INFO_DATA_LIST: RecruitInfo[] = [
    {
        id: 1,
        needs_title: "立ち上げ段階の事業で活躍するRailsエンジニア募集!",
        work_context: "立ち上げ段階のRails事業を共に作り上げて行ける方を探しています。リモートワーク、フレックスタイムを導入しているので柔軟な働き方が可能です。また、社員の8割がエンジニアであり、技術的関心が高く、社員のエンジニアとしてのキャリアパスを支える施策も行っています。(定期的な勉強会、社内ハッカソン、メンターとの1ON1)興味を持った方はカジュアル面談からで良いのでお申し込みください。技術的に成長して、サービスをグロースさせる力のあるエンジニアになりましょう!",
        user_id: "user1",
        created_at: new Date(),
        updated_at: null,
    }, ...省略
]

export default RECRUIT_INFO_DATA_LIST;

各種機能をコーディング

ルートパスは簡易APIドキュメントにしました。

index.ts
// route path(easy api doc)
app.get("/", (req: Request, res: Response) => {

    const easyDoc = {
        implementedUrls: [
            {
                selectAll: "/api/v1/recruits",
                searchByKeyword: "/api/v1/match?keyword=<Keyword>",
            }
        ]
    }

    res.status(200).send(easyDoc).json
});

一覧表示と検索は以下の感じで実装しました。コメントに詳細書いてます。

index.ts
// 型定義とそのデータリストを使うためにモジュールを読み込みます。
import RECRUIT_INFO_DATA_LIST from "./data/recruitlist";
import RecruitInfo from "./model/recruitinfo";
----ほかコード省略----
// 募集一覧
app.get("/api/v1/recruits", (req: Request, res: Response) => {
    const selectAll = RECRUIT_INFO_DATA_LIST;
    res.status(200).send(selectAll).json
});

// 募集検索
app.get("/api/v1/match", (req: Request, res: Response) => {

    // クエリパラム
    const query: string= String(req.query.keyword ?? "");
    // ダミーデータリスト
    const selectAll = RECRUIT_INFO_DATA_LIST;
    // 検索結果のリストのいれもの
    let resultList : RecruitInfo[] = [];

    // 回して一致したものを検索結果リストにつめていく(DB連携の場合はSQLクエリのパラムにクエリパラムをあてる)
    selectAll.forEach((recruitInfo: RecruitInfo, i: number) => {
        if (recruitInfo.needs_title.includes(query)) {
            resultList.push(recruitInfo)
        }else if(recruitInfo.work_context.includes(query)){
            resultList.push(recruitInfo);
        }
    })

    // 検索結果がなかった場合は404を返す
    if(resultList.length == 0){
        res.status(404).send({code: 404, info: "data not found"}).json
        return
    }

    res.status(200).send(resultList).json
});

APIのCORS許可

クライアントからのリクエスト送信のために許可。クラアントで使うために、とりあえず全許可。

index.ts
// CORSを許可する(オリジンを許可しないと任意のクライアントからリクエストを送れない, オリジンを特定化する場合もある)
app.use(function(req: Request, res: Response, next: NextFunction) {
    res.header("Access-Control-Allow-Origin", "*");
    res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
    next();
});

APIの実装が一通り終了したので動作確認してみます。

APIの動作確認結果

GETだけなのでブラウザで適当に確認。プラグインを入れてるのでJSON結果にハイライトが
かかってます。

とりあえず一通り動きましたね。これでDocker上のTypeScript + Expressの簡易APIが出来ました。

クリックで結果確認




テスト

せっかくなのでテストも勉強がてら通せるようにやってみます。

テスト環境の作成

jestでのテストを行うための設定を行います。

依存関係を導入

dockerコンテナのbashに入って、依存関係を追加します。

# jestとjestのTypeScript対応依存関係、Express用テスト依存関係を追加
npm install -D jest ts-jest supertest
# 型定義ファイルの追加
npm install -D @types/jest @types/supertest

ファイルの設定

※jsonのコメントは解説のために記述してますが、実際にはエラーになるので
試す際は消してください。

package.json
  "jest": {
    // .ts .tsxをjestと互換性をもたせる。書かないとTypeScriptのコードはSyntaxErrorになる
    "transform": {
      "^.+\\.tsx?$": "ts-jest"
    },
    "testEnvironment": "node",
    // node_moduleはテストの対象外にする。依存関係にカバレッジをかける意味はない。
    "coveragePathIgnorePatterns": [
      "/node_modules/"
    ]
  },
  "scripts": {
    // テストコマンドを追加
    "test": "jest",
    "start": "ts-node src/index.ts"
  },

テスト対象のパスを設定するオプションもありますが、設定せずとも
src/test/xxx.test.tsのようなファイルは読み込んでくれるので記述してません。

"roots": ["<rootDir>/src/test"],

テストケースの作成

環境が整ったのでテストを作成します。
今回は、「APIのレスポンスで返ってくるオブジェクトアイテムがRecruitInfo型である」か
テストします。

ユーザー定義型ガードを作成

こちらの記事にあるように直感的に判定(typeof使う etc)をすることは不可能なのでガードを作成します。
https://zenn.dev/nekoniki/articles/d40acfd3c26583#ユーザ定義型ガードを使おう

itemの引数にJSONレスポンスを指定して使う形でメソッドを作成します。

// ユーザー定義型ガードメソッド
// レスポンスがRecruitInfo型かどうか判定する。
const isRecruitInfo = (item: any): item is RecruitInfo =>{
    const forcedCastItem = item as RecruitInfo;
    // 募集タイトルと業務内容があればRecruitInfo型として判定する。
    // コード的にはキャストしたitemにneeds_titleとwork_contextが存在してるか判定
    return !!forcedCastItem?.needs_title && !!forcedCastItem?.work_context;
}

テストコードの全体。

基本的な使い方のexpect toBeで検証します。

クリックで全体確認
/test/responese.test.ts
import RecruitInfo from "../model/recruitinfo";

// HTTPリクエストを行う関数
const fetchToRecruits = async () => {
    const res = await fetch("http://localhost:3000/api/v1/recruits");

    if (!res.ok) {
        throw new Error('Failed to fetch recruits');
    }
    const data: RecruitInfo[] = await res.json();
    return data;
};

// ユーザー定義型ガードメソッド
// レスポンスがRecruitInfo型かどうか判定する。
const isRecruitInfo = (item: any): item is RecruitInfo =>{

    const forcedCastItem = item as RecruitInfo;
    // 募集タイトルと業務内容があればRecruitInfo型として判定する。
    return !!forcedCastItem?.needs_title && !!forcedCastItem?.work_context;
}
describe("レスポンスのオブジェクトがRecruitInfo型かテスト", () => {
    it("check type", async () => {
        const response = await fetchToRecruits();
        
        // TODO 同じリクエストINFO型かチェックする
        response.forEach((actual:RecruitInfo, i)=>{
            expect(isRecruitInfo(actual)).toBe(true)
        })
    });
});

テスト結果

docker execのほうでbashに入ります。
npm run testを実行した結果無事にレスポンスがRecruitInfo型であることが
テスト出来ました。

追記:リファクタと機能追加

ID別にデータを取得する必要が出てきたのでAPIの設計に追加変更が必要になりました。
そのタイミングでリファクタリングを先に加えます。

レスポンスハンドリング関連を共通化

404, 500のレスポンスを定数ファイルに格納します。

/constants/constantObj.ts
export const NOT_FOUND_OBJECT = { code: 404, info: "data not found" };
export const INTERNAL_SERVER_OBJECT = { code: 500, info: "Internal Server Error due to Invaild Param" };

レスポンスのJSONを返す処理をメソッド化します。レスポンスを返す処理は
このメソッドに置き換えます。

index.ts
/**
 * レスポンスハンドリングの共通化
 * 
 * @param res 
 * @param statusCode 
 * @param body 
 */
const handleResponse = (res: Response, statusCode: number, body?: any) => {
    res.status(statusCode).send(body).json
}

機能追加

一応データ操作なので、サービス側に分けて書いてます。
※もとの書き方がひどいもの(selectAll[id-1])だったので、調べてマシなものに変更しました

service/recruit.service.ts
import RECRUIT_INFO_DATA_LIST from "../data/recruitlist";
import RecruitInfo from "../model/recruitinfo";

const selectAll = RECRUIT_INFO_DATA_LIST;
==== 上のコード省略 ====
/**
 * id別検索
 * 
 * @param id 募集データID
 * @returns 募集情報(単体)
 */
export const findById = (id: number): RecruitInfo | undefined => {
    // find メソッドは、指定した条件を満たす最初の要素を配列から返します。
    return selectAll.find((recruitInfo) => recruitInfo.id == id);
}
index.ts
// 募集ID別選択
app.get("/api/v1/recruit/:id", (req: Request, res: Response) => {

    const pathId = req.params.id;
    // path idなしか数値以外を指定した場合は500を返す
    if (pathId == null || !isNumeric(pathId)) {
        handleResponse(res, 500, INTERNAL_SERVER_OBJECT);
        return 
    }

    let resultById = findById(Number(pathId));

    if (resultById == null) {
        handleResponse(res, 404, NOT_FOUND_OBJECT);
        return 
    }
    handleResponse(res, 200, resultById);
});
==== 中間ソース省略 ====
/**
 * 文字列の数値判定(正規表現ベース)
 * 
 * @param value  
 * @returns true | false
 */
const isNumeric = (value: string): boolean => {
    // 数値を表す正規表現
    const numericRegex = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;

    // 正規表現でマッチするかチェック
    return numericRegex.test(value);
}

テストケース追加

本来なら404、500番のテストも必要かもですが、取り急ぎ正常系だけ検証しておきました。
型ガードは先述のものを使っています。

/test/responese.test.ts
// HTTPリクエストを行う関数(id別)
const fetchToRecruit = async () => {
    const res = await fetch("http://localhost:3000/api/v1/recruit/2");

    if (!res.ok) {
        throw new Error('Failed to fetch recruit');
    }
    const data: RecruitInfo = await res.json();
    return data;
};

// PASSED
describe("レスポンスのオブジェクトがRecruitInfo型かテスト(id別)", () => {
    it("check type", async () => {
        const response = await fetchToRecruit();
        expect(isRecruitInfo(response)).toBe(true);
    });
});

動作確認

クリックで全体確認

正常系

404

500(パラメータ不正)

GitHub

ソースを全体的に確認する際は確認してみてください。
https://github.com/ryryryry0321/fake-engineer-recruit-api

完了

これで環境構築からテストまでは一通り網羅しました。
今回は取り急ぎAPIが必要だったので、より詳細なことは後ほど更新するか
スクラップでメモします。

Discussion