Node.js(TypeScript)とAzure FunctionsでサーバレスなREST APIを作り、おまけにCosmosDBのChange Feedを受信してみる。

![diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822052/d89737b6-b407-5202-528f-2208a8fd1913.png)

# はじめに
今回の記事では、azure functionとComsmosDBを使ってサーバレスなREST APIのアプリケーションを作成する。使用言語はNode.js/TypeScript。さらにデータベースとしてCosmosDBを使っているのでChange FeedのイベントをAzure functionのトリガーを使っておまけに受信してみる。

# ソースコード
https://github.com/ryuichi-f/az-function-rest-api

# リソースの作成

## リソースグループの作成

```bash
az group create \
        --name ${AZ_RESOURCE_GROUP} \
        --location ${AZ_RESOURCE_GROUP_LOCATION}
```

## Azure Function App リソースの作成

Azure Function Appリソースを作成する前に、BLOB、キュー、テーブルストレージをサポートするためのAzure Storage Accountの作成が必要。

```bash
az storage account create \
    --name ${AZ_STORAGE_ACCOUNT} \
    --location ${AZ_RESOURCE_GROUP_LOCATION} \
    --resource-group ${AZ_RESOURCE_GROUP} \
    --sku Standard_LRS
```

上で作成したAzure Storage Accountの名前を`--storage-account`にパラメータとして渡し、Azure Function Appのリソースを作成する。

```bash
az functionapp create \
    --name ${AZ_FUNCTION} \
    --storage-account ${AZ_STORAGE_ACCOUNT} \
    --consumption-plan-location ${AZ_RESOURCE_GROUP_LOCATION} \
    --resource-group ${AZ_RESOURCE_GROUP} \
    --functions-version 4 \
    --runtime node
```

## CosmosDB リソースの作成

CosmosDB リソースを作成する。デモ又はテスト目的の場合は必ず`--enable-free-tier`に`true`を渡す。

```bash
az cosmosdb create \
    --resource-group ${AZ_RESOURCE_GROUP} \
    --name ${AZ_COSMOS_DB} \
    --enable-free-tier true \
    --enable-analytical-storage false
```

# Azure Function ローカルプロジェクトの作成

## プロジェクトフォルダーの作成

```bash
mkdir az-function-rest-api
cd az-function-rest-api
npm init -y
```

## ライブラリーのインストール

```bash
npm install --save-exact @azure/cosmos
```

## 開発ライブラリーのインストール

```bash
npm install --save-exact -D @azure/functions @types/node azure-functions-core-tools typescript
```

## Azure functionプロジェクトの生成

```
npx func init --worker-runtime node --language typescript
```

## Http trigger functionの作成

今回は4つfunctionを作成する。

```bash
npx func new --name get-todos --template 'HTTP trigger' --authlevel 'anonymous' --language typescript
```

```bash
npx func new --name post-todos --template 'HTTP trigger' --authlevel 'anonymous' --language typescript
```

```bash
npx func new --name delete-todos --template 'HTTP trigger' --authlevel 'anonymous' --language typescript
```

```bash
npx func new --name patch-todos --template 'HTTP trigger' --authlevel 'anonymous' --language typescript
```

## package.jsonを編集

```json:package.json
{
  "name": "az-function-rest-api",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "tsc", # <= 追加
    "watch": "tsc -w", # <= 追加
    "prestart": "npm run build", # <= 追加
    "start": "func start" # <= 追加
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@azure/cosmos": "3.17.1"
  },
  "devDependencies": {
    "@azure/functions": "3.2.0",
    "@types/node": "18.11.5",
    "azure-functions-core-tools": "4.0.4829",
    "typescript": "4.8.4"
  }
}
```

## 現時点でのプロジェクトの構成

```bash
.
├── delete-todos
│   ├── function.json
│   └── index.ts
├── get-todos
│   ├── function.json
│   └── index.ts
├── host.json
├── local.settings.json
├── package-lock.json
├── package.json
├── patch-todos
│   ├── function.json
│   └── index.ts
├── post-todos
│   ├── function.json
│   └── index.ts
└── tsconfig.json
```

## Azure functionをローカルで実行

```bash
npm run start
```

ディフォルトでは、それぞれのfunctionのファルダー名がエンドポイントのpathとして自動的に設定される。

```bash
❯ npm run start

> az-function-rest-api@1.0.0 prestart
> npm run build


> az-function-rest-api@1.0.0 build
> tsc


> az-function-rest-api@1.0.0 start
> func start


Azure Functions Core Tools
Core Tools Version:       4.0.4829 Commit hash: N/A  (64-bit)
Function Runtime Version: 4.11.2.19273

[2022-10-25T05:54:42.841Z] Worker process started and initialized.

Functions:

        delete-todos: [GET,POST] http://localhost:7071/api/delete-todos

        get-todos: [GET,POST] http://localhost:7071/api/get-todos

        patch-todos: [GET,POST] http://localhost:7071/api/patch-todos

        post-todos: [GET,POST] http://localhost:7071/api/post-todos
```

実際にHTTP requestを送ってみると、ちゃんとresponseが返ってくる。

```bash
curl "http://localhost:7071/api/delete-todos?name=azure"
```

```bash
❯ curl "http://localhost:7071/api/get-todos?name=azure"
Hello, azure. This HTTP triggered function executed successfully.%
```

エンドポイントのpath、及びHTTP methodはぞれぞれのfunctionのフォルダーに自動生成されている`function.json`から設定できる。

- `bindings.methods`のリストに許可したいHTTP methdを指定できる。
- `bidnings.route`でエンドポイントのpathが指定できる。path paramもプレースホルダとして指定でき、functionから参照できるようになる。`?`をつけることによりpath paramをオプショナルにすることができる。

```json:get-todos/function.json
{
  "bindings": [
    {
      "authLevel": "Anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": ["get"], # <= postを消去
      "route": "todos/{id?}" # <= 追加
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "scriptFile": "../dist/get-todos/index.js"
}
```

path paramを受け取って見るために、`get-todo/index.ts`を編集する。functionの引数から受け取る事ができる`context`オブジェクトの`bindingData`プラパティにpath paramのデータが格納されてる。

```typescript:get-todos/index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions"

const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise<void> {
    const id = context.bindingData.id; // <= context.bindingData のなかにpath paramのデータが保管されている。

    context.res = {
        body: {
            id
        }
    };

};

export default httpTrigger;
```

functionをもう一度実行してみると、`function.json` で設定した内容が反映されていることが確認できる。

```bash
npm run start
```

```bash
❯ npm run start

> az-function-rest-api@1.0.0 prestart
> npm run build


> az-function-rest-api@1.0.0 build
> tsc


> az-function-rest-api@1.0.0 start
> func start


Azure Functions Core Tools
Core Tools Version:       4.0.4829 Commit hash: N/A  (64-bit)
Function Runtime Version: 4.11.2.19273

[2022-10-25T06:16:51.255Z] Worker process started and initialized.

Functions:

        delete-todos: [GET,POST] http://localhost:7071/api/delete-todos

        get-todos: [GET] http://localhost:7071/api/todos/{id?} # <= 設定が反映されている。

        patch-todos: [GET,POST] http://localhost:7071/api/patch-todos

        post-todos: [GET,POST] http://localhost:7071/api/post-todos
```

path paramをrouteに渡して、HTTP requestを送ってみるとしっかりpath paramに乗せた`id`が返ってくる事が確認できる。

```bash
curl -i -X GET http://localhost:7071/api/todos/123
```

```bash
❯ curl -i -X GET http://localhost:7071/api/todos/123
{
  "id": 123
}%
```

# REST APIの作成

## 完成後のプロジェクト構造

```bash
.
├── delete-todos
│   ├── function.json
│   └── index.ts
├── get-todos
│   ├── function.json
│   └── index.ts
├── host.json
├── lib
│   ├── db
│   │   └── db-config.ts
│   ├── dtos
│   │   ├── CreateTodoDto.ts
│   │   └── UpdateTodoDto.ts
│   ├── errors
│   │   └── HttpError.ts
│   ├── models
│   │   └── TodoItem.ts
│   ├── repositories
│   │   └── todo-repository.ts
│   ├── services
│   │   └── todo-service.ts
│   └── utils
│       └── generate-id.ts
├── local.settings.json
├── package-lock.json
├── package.json
├── patch-todos
│   ├── function.json
│   └── index.ts
├── post-todos
│   ├── function.json
│   └── index.ts
└── tsconfig.json
```

## Get todos function

```typescript:get-todos/index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
import { HttpError } from "../lib/errors/HttpError";
import { todoService } from "../lib/services/todo-service";

const httpTrigger: AzureFunction = async function (
  context: Context,
  req: HttpRequest
): Promise<void> {
  const todoId = context.bindingData.id;

  try {
    if (todoId) {
      const result = await todoService.getOne(todoId);
      context.res = {
        status: 200,
        body: result,
      };

      return;
    }
  } catch (err) {
    if (err instanceof HttpError) {
      context.res = {
        status: err.StatusCode,
        body: {
          error: {
            message: err.message,
          },
        },
      };
      return;
    }
  }

  const result = await todoService.getOnes();

  context.res = {
    status: 200,
    body: result,
  };
};

export default httpTrigger;
```

```json:get-todos/function.json
{
  "bindings": [
    {
      "authLevel": "Anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": ["get"],
      "route": "todos/{id?}"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "scriptFile": "../dist/get-todos/index.js"
}

```

## Post todos function

```typescript:post-todos/index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
import { todoService } from "../lib/services/todo-service";

const httpTrigger: AzureFunction = async function (
  context: Context,
  req: HttpRequest
): Promise<void> {
  const todoCreateDto = req.body;
  const result = await todoService.createOne(todoCreateDto);

  context.res = {
    status: 201,
    body: result,
  };
};

export default httpTrigger;
```

```json:post-todos/function.json
{
  "bindings": [
    {
      "authLevel": "Anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": ["post"],
      "route": "todos"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "scriptFile": "../dist/post-todos/index.js"
}
```

## Delete todos function

```typescript:delete-todos/index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
import { HttpError } from "../lib/errors/HttpError";
import { todoService } from "../lib/services/todo-service";

const httpTrigger: AzureFunction = async function (
  context: Context,
  req: HttpRequest
): Promise<void> {
  const todoId = context.bindingData.id;

  try {
    await todoService.deleteOne(todoId);
  } catch (err) {
    if (err instanceof HttpError) {
      context.res = {
        status: err.StatusCode,
        body: {
          error: {
            message: err.message,
          },
        },
      };
      return;
    }
  }

  context.res = {
    status: 204,
  };
};

export default httpTrigger;
```

```json:delete-todos/function.json
{
  "bindings": [
    {
      "authLevel": "Anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": ["delete"],
      "route": "todos/{id}"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "scriptFile": "../dist/delete-todos/index.js"
}
```

## Patch todos function

```typescript:patch-todos/index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
import { HttpError } from "../lib/errors/HttpError";
import { todoService } from "../lib/services/todo-service";

const httpTrigger: AzureFunction = async function (
  context: Context,
  req: HttpRequest
): Promise<void> {
  const todoId = context.bindingData.id;
  const updateTodoDto = req.body;

  try {
    await todoService.updateOne(updateTodoDto, todoId);
  } catch (err) {
    if (err instanceof HttpError) {
      context.res = {
        status: err.StatusCode,
        body: {
          error: {
            message: err.message,
          },
        },
      };
      return;
    }
  }

  context.res = {
    status: 204,
  };
};

export default httpTrigger;
```

```json:patch-todos/function.json
{
  "bindings": [
    {
      "authLevel": "Anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": ["patch"],
      "route": "todos/{id}"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "scriptFile": "../dist/patch-todos/index.js"
}
```

## 共通モデュール

## モデル

```typescript:lib/models/TodoItem.ts
export interface TodoItem {
  id?: string;
  title: string;
  isCompleted: boolean;
}
```

## サービス

```typescript:lib/services/todo-service.ts
import { CreateTodoDto } from "../dtos/CreateTodoDto";
import { UpdateTodoDto } from "../dtos/UpdateTodoDto";
import { HttpError } from "../errors/HttpError";
import { TodoItem } from "../models/TodoItem";
import { todoRepository } from "../repositories/todo-repository";
import { generateId } from "../utils/generate-id";

const getOnes = async (): Promise<TodoItem[] | any> => {
  return await todoRepository.getOnes();
};

const getOne = async (id: string): Promise<any> => {
  return await todoRepository.getOneById(id);
};

const createOne = async (dto: CreateTodoDto): Promise<TodoItem> => {
  const newTodo: TodoItem = {
    id: generateId(),
    ...dto,
    isCompleted: false,
  };

  return await todoRepository.createOne(newTodo);
};

const deleteOne = async (id: string) => {
  if (!id) {
    throw new HttpError("Todo item id is not provided", 400);
  }

  const existingTodo = await todoRepository.getOneById(id);
  if (!existingTodo) {
    throw new HttpError(`TodoItem(id=${id}) is not found.`, 404);
  }

  todoRepository.removeOneById(existingTodo.id);
};

const updateOne = async (dto: UpdateTodoDto, id: string) => {
  if (!id) {
    throw new HttpError("Todo item id is not provided", 400);
  }

  const existingTodo = await todoRepository.getOneById(id);

  if (!existingTodo) {
    throw new HttpError(`TodoItem(id=${id}) is not found.`, 404);
  }

  if (typeof dto.title !== "undefined") {
    existingTodo.title = dto.title;
  }

  if (typeof dto.isCompleted !== "undefined") {
    existingTodo.isCompleted = dto.isCompleted;
  }

  await todoRepository.update(existingTodo);
};

export const todoService = Object.freeze({
  getOnes,
  getOne,
  createOne,
  deleteOne,
  updateOne,
});
```

## レポジトリー

```typescript:lib/repositorieds/todo-repository.ts
import { initDB } from "../db/db-config";

const db = initDB();

const getOnes = async () => {
  const { container } = await db;
  const { resources } = await container.items.readAll().fetchAll();
  return resources;
};

const getOneById = async (id: string) => {
  const { container } = await db;
  const { resource } = await container.item(id, id).read();
  return resource;
};

const createOne = async (todo: any) => {
  const { container } = await db;
  const { resource } = await container.items.create(todo);
  return resource;
};

const removeOneById = async (id: string) => {
  const { container } = await db;
  await container.item(id, id).delete();
};

const update = async (todo: any) => {
  const { container } = await db;
  await container.item(todo.id, todo.id).replace(todo);
};

export const todoRepository = Object.freeze({
  getOnes,
  getOneById,
  createOne,
  removeOneById,
  update,
});
```

## データベース設定

```typescript:lib/db/db-config.ts
import { CosmosClient } from "@azure/cosmos";

const cosmosConfig = {
  endpoint: process.env.COSMOSDB_URI,
  primaryKey: process.env.COSMOSDB_PRIMARY_KEY,
  database: process.env.COSMOSDB_DATABASE,
  container: process.env.COSMOSDB_CONTAINER,
  partitionKey: {
    paths: ["/id"],
  },
};

export const initDB = async () => {
  const cosmosClient = new CosmosClient({
    endpoint: cosmosConfig.endpoint,
    key: cosmosConfig.primaryKey,
  });
  const { database } = await cosmosClient.databases.createIfNotExists({
    id: cosmosConfig.database,
  });

  const { container } = await database.containers.createIfNotExists({
    id: cosmosConfig.container,
    partitionKey: cosmosConfig.partitionKey,
  });

  return {
    cosmosClient,
    database,
    container,
  };
};
```

## カスタムエラー

```typescript:lib/errors/HttpError.ts
export class HttpError extends Error {
  statusCode;
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
  }

  get StatusCode() {
    return this.statusCode;
  }
}
```

## ユーティリティ

```typescript:lib/utils/generate-id.ts
import * as crypto from "crypto";

export const generateId = () => crypto.randomUUID();
```

## その他設定ファイル

```json:host.json
{
  "version": "2.0",
  "logging": {
    "applicationInsights": {
      "samplingSettings": {
        "isEnabled": true,
        "excludedTypes": "Request"
      }
    }
  },
  "extensionBundle": {
    "id": "Microsoft.Azure.Functions.ExtensionBundle",
    "version": "[3.*, 4.0.0)"
  }
```

`local.settings.json`で環境変数などを設定できる。CosmosDBに接続するための`COSMOSDB_URI`と`COSMOSDB_PRIMARY_KEY`を環境変数として追加する。`COSMOSDB_DATABASE`と`COSMOSDB_CONTAINER`の値はぞれぞれ任意の値を指定する。

```json:local.settings.json
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "node",
    "COSMOSDB_URI": "https://<COSMOSDB_RESOURCE_NAME>.documents.azure.com:443/",
    "COSMOSDB_PRIMARY_KEY": "<COSMOSDB_PRIMARY_KEY>",
    "COSMOSDB_DATABASE": "node_azure_functions_db",
    "COSMOSDB_CONTAINER": "todos"
  }
}
```

`COSMOSDB_URI`と`COSMOSDB_PRIMARY_KEY`の値は、azure portalの「CosmosDB リソース => Settgins => Keys」から確認できる。
![Screen Shot 2022-10-26 at 13.30.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822052/6d350ade-7e75-858a-3bd5-491de8af3cd4.png)

又は以下のコマンドで取得できる。

### CosmosDB URI
```bash
az cosmosdb show \
    --resource-group ${AZ_RESOURCE_GROUP} \
    --name ${AZ_COSMOS_DB} \
    -o tsv \
    --query "documentEndpoint"
```

### CosmosDB Primary Key
```bash
az cosmosdb keys list \
    --resource-group ${AZ_RESOURCE_GROUP} \
    --name ${AZ_COSMOS_DB} \
    --type "keys" \
    -o tsv \
    --query "primaryMasterKey"
```

## REST APIを起動する

```bash
npm run start
```

```bash
❯ npm run start

> az-function-rest-api@1.0.0 prestart
> npm run build


> az-function-rest-api@1.0.0 build
> tsc


> az-function-rest-api@1.0.0 start
> func start


Azure Functions Core Tools
Core Tools Version:       4.0.4829 Commit hash: N/A  (64-bit)
Function Runtime Version: 4.11.2.19273

[2022-10-26T02:25:55.113Z] Worker process started and initialized.

Functions:

        delete-todos: [DELETE] http://localhost:7071/api/todos/{id}

        get-todos: [GET] http://localhost:7071/api/todos/{id?}

        patch-todos: [PATCH] http://localhost:7071/api/todos/{id}

        post-todos: [POST] http://localhost:7071/api/todos
```

実際に、以下のコマンドを叩いて、APIをテストしてみる。

```bash
curl -i -X POST -d '{"title":"todo 1"}' http://localhost:7071/api/todos
```

```bash
curl -i -X GET http://localhost:7071/api/todos
```

```bash
curl -i -X GET http://localhost:7071/api/todos/{id}
```

```bash
curl -i -X PATCH -d '{"title":"todo 1 updated"}' http://localhost:7071/api/todos/{id}
```

```bash
curl -i -X DELETE http://localhost:7071/api/todos/{id}
```

### todo itemを追加
```bash
❯ curl -i -X POST -d '{"title":"todo 1"}' http://localhost:7071/api/todos
HTTP/1.1 201 Created
Content-Type: text/plain; charset=utf-8
Date: Wed, 26 Oct 2022 02:53:40 GMT
Server: Kestrel
Transfer-Encoding: chunked

{
  "id": "10cb9c6f-6161-4798-8de5-e213a5e2be1d",
  "title": "todo 1",
  "isCompleted": false,
  "_rid": "8T0VAJe9jpsDAAAAAAAAAA==",
  "_self": "dbs/8T0VAA==/colls/8T0VAJe9jps=/docs/8T0VAJe9jpsDAAAAAAAAAA==/",
  "_etag": "\"c001744e-0000-2300-0000-6358a1350000\"",
  "_attachments": "attachments/",
  "_ts": 1666752821
}%  
```

### todo itemを取得

```bash
❯ curl -i -X GET http://localhost:7071/api/todos
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Wed, 26 Oct 2022 02:55:41 GMT
Server: Kestrel
Transfer-Encoding: chunked

[
  {
    "id": "10cb9c6f-6161-4798-8de5-e213a5e2be1d",
    "title": "todo 1",
    "isCompleted": false,
    "_rid": "8T0VAJe9jpsDAAAAAAAAAA==",
    "_self": "dbs/8T0VAA==/colls/8T0VAJe9jps=/docs/8T0VAJe9jpsDAAAAAAAAAA==/",
    "_etag": "\"c001744e-0000-2300-0000-6358a1350000\"",
    "_attachments": "attachments/",
    "_ts": 1666752821
  }
]% 
```

### todo itemを編集
```bash
❯ curl -i -X PATCH -d '{"title":"todo 1 updated"}' http://localhost:7071/api/todos/10cb9c6f-6161-4798-8de5-e213a5e2be1d
HTTP/1.1 204 No Content
Content-Type: text/plain; charset=utf-8
Date: Wed, 26 Oct 2022 02:56:31 GMT
Server: Kestrel
```

### todo itemをIDで取得

```bash
❯ curl -i -X GET http://localhost:7071/api/todos/10cb9c6f-6161-4798-8de5-e213a5e2be1d
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Wed, 26 Oct 2022 02:57:34 GMT
Server: Kestrel
Transfer-Encoding: chunked

{
  "id": "10cb9c6f-6161-4798-8de5-e213a5e2be1d",
  "title": "todo 1 updated",
  "isCompleted": false,
  "_rid": "8T0VAJe9jpsDAAAAAAAAAA==",
  "_self": "dbs/8T0VAA==/colls/8T0VAJe9jps=/docs/8T0VAJe9jpsDAAAAAAAAAA==/",
  "_etag": "\"c0013f9b-0000-2300-0000-6358a1e00000\"",
  "_attachments": "attachments/",
  "_ts": 1666752992
}%
```
しっかりazure portal側でもCosmosDBにデータが追加されていることが確認。
![Screen Shot 2022-10-26 at 12.01.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822052/e5f389a6-f3c9-edca-0952-72a90f091b7f.png)



# Change Feed リスナー functionの作成

```bash
npx func new --name change-feed-listener --template 'Azure Cosmos DB trigger' --language typescript
```

```bash
.
├── change-feed-listener # <= 追加
│   ├── function.json
│   └── index.ts
├── delete-todos
│   ├── function.json
│   └── index.ts
├── get-todos
│   ├── function.json
│   └── index.ts
├── host.json
├── lib
│   ├── db
│   │   └── db-config.ts
│   ├── dtos
│   │   ├── CreateTodoDto.ts
│   │   └── UpdateTodoDto.ts
│   ├── errors
│   │   └── HttpError.ts
│   ├── models
│   │   └── TodoItem.ts
│   ├── repositories
│   │   └── todo-repository.ts
│   ├── services
│   │   └── todo-service.ts
│   └── utils
│       └── generate-id.ts
├── local.settings.json
├── package-lock.json
├── package.json
├── patch-todos
│   ├── function.json
│   └── index.ts
├── post-todos
│   ├── function.json
│   └── index.ts
└── tsconfig.json
```

`cosmosDBTrigger` functionは接続しているCosmosDBにデータが追加されたり、編集されたりすると実行され、そのレコードのデータが渡される。データの消去時には`cosmosDBTrigger`は実行されない。

```typescript:change-feed-listener/index.ts
import { AzureFunction, Context } from "@azure/functions"

const cosmosDBTrigger: AzureFunction = async function (context: Context, documents: any[]): Promise<void> {
    if (!!documents && documents.length > 0) {
        context.log('Document:', documents);
    }
}

export default cosmosDBTrigger;
```

`connectionStringSetting`の値に直接connection stringの値を代入して実行するとエラーになる。実際の値は`local.settings.json`に書き込んで、`connectionStringSetting`の値は`COSMOSDB_CONNECTION_STRING_DOCUMENTDB`と置いておく。

```json:change-feed-listener/function.json
{
  "bindings": [
    {
      "type": "cosmosDBTrigger",
      "name": "documents",
      "direction": "in",
      "leaseCollectionName": "leases",
      "connectionStringSetting": "COSMOSDB_CONNECTION_STRING_DOCUMENTDB", # <= local.settings.jsonの値を参照。
      "databaseName": "node_azure_functions_db",
      "collectionName": "todos",
      "createLeaseCollectionIfNotExists": true
    }
  ],
  "scriptFile": "../../dist/src/change-feed-listener/index.js"
}
```

`local.settings.json`を編集し、`COSMOSDB_CONNECTION_STRING_DOCUMENTDB`を追加する。`COSMOSDB_CONNECTION_STRING_DOCUMENTDB`の値は、`COSMOSDB_URI`と`COSMOSDB_PRIMARY_KEY`の組み合わせ。keyの名前の最後に必ず`_DOCUMENTDB`を付ける(`function.json` から値を参照をするために必要)。

```json:local.settings.json
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "node",
    "COSMOSDB_URI": "https://<COSMOSDB_NAME>.documents.azure.com:443/",
    "COSMOSDB_PRIMARY_KEY": "<COSMOSDB_PRIMARY_KEY>",
    "COSMOSDB_DATABASE": "node_azure_functions_db",
    "COSMOSDB_CONTAINER": "todos"
    # 追加
    "COSMOSDB_CONNECTION_STRING_DOCUMENTDB": "AccountEndpoint=https://<COSMOSDB_NAME>.documents.azure.com:443/;AccountKey=<COSMOSDB_PRIMARY_KEY>;"
  }
}
```

又はazure portalの「CosmosDB リソース => Settgins => Keys」から確認できる。
![Screen Shot 2022-10-26 at 13.31.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822052/010beef0-4bf1-a996-e1b9-324b21c83cf0.png)

```bash
npm run start
``` 

新しく`change-feed-listener: cosmosDBTrigger`がfunctionとして追加されている。

```bash
❯ npm run start

> az-function-rest-api@1.0.0 prestart
> npm run build


> az-function-rest-api@1.0.0 build
> tsc


> az-function-rest-api@1.0.0 start
> func start


Azure Functions Core Tools
Core Tools Version:       4.0.4829 Commit hash: N/A  (64-bit)
Function Runtime Version: 4.11.2.19273

[2022-10-26T05:15:06.386Z] Worker process started and initialized.

Functions:

        delete-todos: [DELETE] http://localhost:7071/api/todos/{id}

        get-todos: [GET] http://localhost:7071/api/todos/{id?}

        patch-todos: [PATCH] http://localhost:7071/api/todos/{id}

        post-todos: [POST] http://localhost:7071/api/todos

        change-feed-listener: cosmosDBTrigger # <= 追加
``` 

実際にデータを追加してみると。

```bash
❯ curl -i -X POST -d '{"title":"todo 1"}' http://localhost:7071/api/todos
HTTP/1.1 201 Created
Content-Type: text/plain; charset=utf-8
Date: Wed, 26 Oct 2022 05:40:11 GMT
Server: Kestrel
Transfer-Encoding: chunked

{
  "id": "29c84398-6b37-4dfd-ad8b-45403a62f4d7",
  "title": "todo 1",
  "isCompleted": false,
  "_rid": "8T0VAJe9jpsEAAAAAAAAAA==",
  "_self": "dbs/8T0VAA==/colls/8T0VAJe9jps=/docs/8T0VAJe9jpsEAAAAAAAAAA==/",
  "_etag": "\"cf01af89-0000-2300-0000-6358c83b0000\"",
  "_attachments": "attachments/",
  "_ts": 1666762811
}%
``` 
`change-feed-listener` のログにchange feedから受け取ったデータが出力され、`change-feed-listener` functionが無事に実行されたことが確認できる。
      

```bash
[2022-10-26T05:40:16.500Z] Executing 'Functions.change-feed-listener' (Reason='New changes on collection todos at 2022-10-26T05:40:16.4919090Z', Id=50591fbf-9ef8-49cc-b34a-abbdca3631d1)
[2022-10-26T05:40:16.519Z] Document: [
[2022-10-26T05:40:16.519Z]   {
[2022-10-26T05:40:16.519Z]     id: '29c84398-6b37-4dfd-ad8b-45403a62f4d7',
[2022-10-26T05:40:16.519Z]     _rid: '8T0VAJe9jpsEAAAAAAAAAA==',
[2022-10-26T05:40:16.519Z]     _self: 'dbs/8T0VAA==/colls/8T0VAJe9jps=/docs/8T0VAJe9jpsEAAAAAAAAAA==/',
[2022-10-26T05:40:16.519Z]     _ts: 1666762811,
[2022-10-26T05:40:16.519Z]     _etag: '"cf01af89-0000-2300-0000-6358c83b0000"',
[2022-10-26T05:40:16.519Z]     title: 'todo 1',
[2022-10-26T05:40:16.519Z]     isCompleted: false,
[2022-10-26T05:40:16.519Z]     _lsn: 11
[2022-10-26T05:40:16.519Z]   }
[2022-10-26T05:40:16.519Z] ]
```   

# Functionsのデプロイメント

プロジェクトのルートディレクトリーで以下のコマンドを実行するだけで、簡単にデプロイメントができる。

```bash
npx func azure functionapp publish ${AZ_FUNCTION}
```

```bash
❯ npx func azure functionapp publish myTestFunction231917910
Setting Functions site property 'netFrameworkVersion' to 'v6.0'
Getting site publishing info...
Creating archive for current directory...
Uploading 1.61 MB [#########################################################]
Upload completed successfully.
Deployment completed successfully.
Syncing triggers...
Functions in myTestFunction231917910:
    change-feed-listener - [cosmosDBTrigger]

    delete-todos - [httpTrigger]
        Invoke url: https://mytestfunction231917910.azurewebsites.net/api/todos/{id}

    get-todos - [httpTrigger]
        Invoke url: https://mytestfunction231917910.azurewebsites.net/api/todos/{id?}

    patch-todos - [httpTrigger]
        Invoke url: https://mytestfunction231917910.azurewebsites.net/api/todos/{id}

    post-todos - [httpTrigger]
        Invoke url: https://mytestfunction231917910.azurewebsites.net/api/todos
```

以下のコマンドでデプロイメントされたfunctionのログを確認することができる。

```bash
npx func azure functionapp logstream ${AZ_FUNCTION}
```

# おわり
Node.js(TypeScript)とAzure FunctionsでサーバレスなREST APIのアプリケーションの作成ができた。azure functionを使えば高速にアプリケーションの開発ができる。デプロイメントもとても簡単。CosmosDBのChange feedも簡単に受信することができるので、azure functionを中心にマイクロサービスのアプリケーションも簡単に追加していく事できる。