Loading...

MBC CQRS サーバーレス フレームワーク TO-DO システム作成 2 – 書き込み処理追加

今回はTO-DOを登録するための処理を作成します。
CQRSパターンであるため、書き込み用データにTO-DOデータの書き込みコマンドを追加し、MBC CQRS サーバーレスフレームワークが自動的に読み込み用DBに反映するところまで確認出来ます。

[option] 不要なマスタモジュールを削除

npm run offline:dockernpm run offline:sls を実行している場合は一旦中止します。

src/master フォルダに関して、今回のTO-DOシステムでは不要なため削除します。
masterモジュールの読込を削除するため、src/main.module.ts を以下の通りとします。

import { Module } from '@nestjs/common'

import { CustomEventFactory } from './event-factory'
import { prismaLoggingMiddleware, PrismaModule } from './prisma'

@Module({
  imports: [
    PrismaModule.forRoot({
      isGlobal: true,
      prismaServiceOptions: {
        middlewares: [prismaLoggingMiddleware()],
        prismaOptions: {
          log:
            process.env.NODE_ENV !== 'local'
              ? ['error']
              : ['info', 'error', 'warn', 'query'],
        },
        explicitConnect: false,
      },
    }),
  ],
  providers: [CustomEventFactory],
})
export class MainModule {}

prisma/dynamodbs/cqrs.jsonmaster が登録されているため以下のようにして登録を解除します。

[]
初期テーブルテーブルの削除

初期に作成されるテーブルを削除するため infra-local/docker-data ディレクトリを削除します。

テーブルの再作成

npm run offline:docker を実行しローカルのDynamoDBサービス等を起動してから npm run migrate を実行しテーブルを再作成します。

> mbc-todo-sample@0.0.1 migrate
> npm run migrate:rds && npm run migrate:ddb


> mbc-todo-sample@0.0.1 migrate:rds
> prisma migrate deploy

Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": MySQL database "cqrs" at "localhost:3306"

No migration found in prisma/migrations


No pending migrations to apply.

> mbc-todo-sample@0.0.1 migrate:ddb
> ts-node prisma/ddb.ts

Clear table stream arn in .env

creating table: sequences

creating table: tasks
table created with arn: ` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-tasks `, and stream arn:` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-tasks/stream/2024-10-06T05:34:11.334 `
table created with arn: ` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-sequences `, and stream arn:` undefined `

2 tables were created successfully
テーブル定義を追加

TO-DO用のテーブルを追加します。
サンプルシステムなので説明、ステータス、期限を追加します。
既存で作成された定義は削除するので src/prismaファイルを以下のようにします。

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "linux-arm64-openssl-1.0.x"]
  // binaryTargets = ["native", "rhel-openssl-1.0.x"]
}

datasource db {
  provider     = "mysql"
  url          = env("DATABASE_URL")
  relationMode = "prisma"
}

enum TaskStatus {
  PENDING
  IN_PROGRESS
  COMPLETED
  CANCELED
}

model Task {
  id         String   @id
  cpk        String // コマンド用PK
  csk        String // コマンド用SK
  pk         String // データ用PK, TASK#unigrab (テナントコード)
  sk         String // データ用SK, マスタ種別コード#マスタコード
  tenantCode String   @map("tenant_code") // テナントコード, 【テナントコードマスタ】
  seq        Int      @default(0) // 並び順, 採番機能を使用する
  code       String // レコードのコード, マスタ種別コード#マスタコード
  name       String // レコード名, 名前
  version    Int // バージョン
  isDeleted  Boolean  @default(false) @map("is_deleted") // 削除フラグ
  createdBy  String   @default("") @map("created_by") // 作成者
  createdIp  String   @default("") @map("created_ip") // 作成IP, IPv6も考慮する
  createdAt  DateTime @default(now()) @map("created_at") @db.Timestamp(0) // 作成日時
  updatedBy  String   @default("") @map("updated_by") // 更新者
  updatedIp  String   @default("") @map("updated_ip") // 更新IP, IPv6も考慮する
  updatedAt  DateTime @updatedAt @map("updated_at") @db.Timestamp(0) // 更新日時

  description String     @default("") @map("description")
  status      TaskStatus @default(PENDING) @map("status")
  dueDate     DateTime?  @map("due_date")

  @@unique([cpk, csk])
  @@unique([pk, sk])
  @@unique([tenantCode, code])
  @@index([tenantCode, name])
  @@map("tasks")
}

prismaのスキーマ定義が完了したら以下のコマンドを実行しmigrateファイルを生成します。

npm run migrate:dev
> mbc-todo-sample@0.0.1 migrate:dev
> prisma migrate dev

Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": MySQL database "cqrs" at "localhost:3306"

✔ Enter a name for the new migration: … create todo table
Applying migration `20241006122941_create_todo_table`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20241006122941_create_todo_table/
    └─ migration.sql

Your database is now in sync with your schema.

✔ Generated Prisma Client (v5.20.0) to ./node_modules/@prisma/client in 45ms
コントローラの追加

以下のコマンドを実行してコントローラを追加します。

nest g controller todo

以下の結果が得られます。

CREATE src/todo/todo.controller.spec.ts (478 bytes)
CREATE src/todo/todo.controller.ts (97 bytes)
UPDATE src/main.module.ts (767 bytes)

以下のコマンドを実行してサービスを追加します。

サービスの追加
nest g service todo

以下の結果が得られます。

CREATE src/todo/todo.service.spec.ts (446 bytes)
CREATE src/todo/todo.service.ts (88 bytes)
UPDATE src/main.module.ts (831 bytes)
モジュールの追加

以下のコマンドを実行してモジュールを追加します。

nest g module todo

以下の結果が得られます。

CREATE src/todo/todo.module.ts (81 bytes)
UPDATE src/main.module.ts (896 bytes)
ヘルパー関数を追加

DynamoDB等に定義する pksk の設定を一元的に管理出来るようにヘルパー関数を登録します。
src/helpers/id.ts を追加し、以下のように入力します。

import { KEY_SEPARATOR } from '@mbc-cqrs-serverless/core'
import { ulid } from 'ulid'

export const TODO_PK_PREFIX = 'TODO'

export function generateTodoPk(tenantCode: string): string {
  return `${TODO_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`
}

export function generateTodoSk(): string {
  return ulid()
}

export function parsePk(pk: string): { type: string; tenantCode: string } {
  if (pk.split(KEY_SEPARATOR).length !== 2) {
    throw new Error('Invalid PK')
  }
  const [type, tenantCode] = pk.split(KEY_SEPARATOR)
  return {
    type,
    tenantCode,
  }
}

上記ヘルパーを src/helper/index.ts に登録します。以下のようになります。

export * from './get-order'
export * from './id'
属性用Dtoを追加

mbc-cqrs-serverlss ではDynamoDBの属性が予約語として決められております。そのため、個別に保存するデータを「attributes」に追加します。
そのため属性用のDtoを定義します。
src/todo/dto/todo-attributes.dto.ts を追加し、以下のように入力します。

import { ApiProperty } from '@nestjs/swagger'
import { TaskStatus } from '@prisma/client'
import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator'

export class TodoAttributes {
  @IsOptional()
  @IsString()
  description?: string

  @IsOptional()
  @ApiProperty({ enum: TaskStatus })
  @IsEnum(TaskStatus)
  status?: TaskStatus

  @IsOptional()
  @IsDateString()
  dueDate?: string
}

To-do 作成用のDtoを作成します。

import { Type } from 'class-transformer'
import { IsOptional, IsString, ValidateNested } from 'class-validator'

import { TodoAttributes } from './todo-attributes.dto'

export class CreateTodoDto {
  @IsString()
  name: string

  @Type(() => TodoAttributes)
  @ValidateNested()
  @IsOptional()
  attributes?: TodoAttributes

  constructor(partial: Partial<CreateTodoDto>) {
    Object.assign(this, partial)
  }
}
Entityの作成

Todoを追加・更新・削除する際に使用するコマンドのEntityを作成します。
src/todo/entity/todo-command.entity.ts を追加し以下のように入力します。

import { CommandEntity } from '@mbc-cqrs-serverless/core'

import { TodoAttributes } from '../dto/todo-attributes.dto'

export class TodoCommandEntity extends CommandEntity {
attributes: TodoAttributes

constructor(partial: Partial<TodoCommandEntity>) {
super()
Object.assign(this, partial)
}
}

データ参照用のEntityを作成します。
src/todo/entity/todo-data.entity.ts を追加し以下のように入力します。

import { DataEntity } from '@mbc-cqrs-serverless/core'

import { TodoAttributes } from '../dto/todo-attributes.dto'

export class TodoDataEntity extends DataEntity {
  attributes: TodoAttributes

  constructor(partial: Partial<TodoDataEntity>) {
    super(partial)
    Object.assign(this, partial)
  }
}
To-do用サービスに追加サービスを追加

To-doサービスにログを登録します。

private readonly logger = new Logger(TodoService.name)

@mbc-cqrs-serverless/core から CommandService を追加した上で constructor にコマンドサービスを追加します。

constructor(private readonly commandService: CommandService) {}

作成ロジックを作成します。

  async create(
    createDto: CreateTodoDto,
    opts: { invokeContext: IInvoke },
  ): Promise<TodoDataEntity> {
    const { tenantCode } = getUserContext(opts.invokeContext)
    const pk = generateTodoPk(tenantCode)
    const sk = generateTodoSk()
    const task = new TodoCommandEntity({
      pk,
      sk,
      id: generateId(pk, sk),
      tenantCode,
      code: sk,
      type: TODO_PK_PREFIX,
      version: VERSION_FIRST,
      name: createDto.name,
      attributes: createDto.attributes,
    })
    const item = await this.commandService.publish(task, opts)
    return new TodoDataEntity(item as TodoDataEntity)
  }

src/todo/todo.service.ts は以下の通りになります。

import {
  CommandService,
  generateId,
  getUserContext,
  IInvoke,
  VERSION_FIRST,
} from '@mbc-cqrs-serverless/core'
import { Injectable, Logger } from '@nestjs/common'

import { CreateTodoDto } from './dto/create-todo.dto'
import { TodoCommandEntity } from './entity/todo-command.entity'
import { TodoDataEntity } from './entity/todo-data.entity'
import { generateTodoPk, generateTodoSk, TODO_PK_PREFIX } from '../helpers'

@Injectable()
export class TodoService {
  private readonly logger = new Logger(TodoService.name)

  constructor(private readonly commandService: CommandService) {}

  async create(
    createDto: CreateTodoDto,
    opts: { invokeContext: IInvoke },
  ): Promise<TodoDataEntity> {
    const { tenantCode } = getUserContext(opts.invokeContext)
    const pk = generateTodoPk(tenantCode)
    const sk = generateTodoSk()
    const task = new TodoCommandEntity({
      pk,
      sk,
      id: generateId(pk, sk),
      tenantCode,
      code: sk,
      type: TODO_PK_PREFIX,
      version: VERSION_FIRST,
      name: createDto.name,
      attributes: createDto.attributes,
    })
    const item = await this.commandService.publish(task, opts)
    return new TodoDataEntity(item as TodoDataEntity)
  }
}
コントローラに追加処理を追加します。

To-doコントローラにログを登録します。

private readonly logger = new Logger(TodoService.name)

todo.service から TodoService を追加した上で constructor にコマンドサービスを追加します。

constructor(private readonly todoService: TodoService) {}

追加処理を追加します。

  @Post('/')
  async create(
    @INVOKE_CONTEXT() invokeContext: IInvoke,
    @Body() createDto: CreateTodoDto,
  ): Promise<TodoDataEntity> {
    this.logger.debug('createDto:', createDto)
    return this.todoService.create(createDto, { invokeContext })
  }

src/todo/todo.controller.ts は次の通りになります。

import { IInvoke, INVOKE_CONTEXT } from '@mbc-cqrs-serverless/core'
import { Body, Controller, Logger, Post } from '@nestjs/common'

import { CreateTodoDto } from './dto/create-todo.dto'
import { TodoDataEntity } from './entity/todo-data.entity'
import { TodoService } from './todo.service'

@Controller('todo')
export class TodoController {
  private readonly logger = new Logger(TodoController.name)

  constructor(private readonly todoService: TodoService) {}

  @Post('/')
  async create(
    @INVOKE_CONTEXT() invokeContext: IInvoke,
    @Body() createDto: CreateTodoDto,
  ): Promise<TodoDataEntity> {
    this.logger.debug('createDto:', createDto)
    return this.todoService.create(createDto, { invokeContext })
  }
}

モジュールを設定します。

To-do コントローラを To-do モジュールに登録します。
src/todo/todo.module.ts を以下のように変更します。

import { CommandModule } from '@mbc-cqrs-serverless/core'
import { Module } from '@nestjs/common'

import { TodoController } from './todo.controller'
import { TodoService } from './todo.service'

@Module({
  imports: [
    CommandModule.register({
      tableName: 'todo',
    }),
  ],
  controllers: [TodoController],
  providers: [TodoService],
})
export class TodoModule {}

prisma/dynamodbs/cqrs.jsontodo 登録します。
以下のようになります。

["todo"]

テーブルを追加

 npm run migrate

以下の結果が表示されます。

> mbc-todo-sample@0.0.1 migrate
> npm run migrate:rds && npm run migrate:ddb


> mbc-todo-sample@0.0.1 migrate:rds
> prisma migrate deploy

Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": MySQL database "cqrs" at "localhost:3306"

1 migration found in prisma/migrations


No pending migrations to apply.

> mbc-todo-sample@0.0.1 migrate:ddb
> ts-node prisma/ddb.ts

Clear table stream arn in .env

creating table: todo-command

creating table: todo-data

creating table: todo-history
table created with arn: ` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-todo-history `, and stream arn:` undefined `
table created with arn: ` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-todo-data `, and stream arn:` undefined `
table created with arn: ` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-todo-command `, and stream arn:` arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-todo-command/stream/2024-10-06T14:17:25.753 `

creating table: sequences

creating table: tasks
table exists: arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-sequences  => stream: undefined
table exists: arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-tasks  => stream: arn:aws:dynamodb:ddblocal:000000000000:table/local-demo-tasks/stream/2024-10-06T05:34:11.334

3 tables were created successfully

ブラウザーで ローカル DynamoDB のテーブルを見るとlocal-demo-todo-command, local-demo-todo-data, local-demo-todo-history が作成されていることが分かります。

データの追加テスト

API ドキュメントの参照

ブラウザーで http://0.0.0.0:3000/swagger-ui/ を参照するとAPIドキュメントを参照することが可能です。

Visual Studio Code の HTTP Clientでテスト

Visual Studio Code で test/api.http を開きます。次のように入力します。

@endpoint = http://localhost:3000
@cognitoEndpoint = http://localhost:9229
@clientId = dnk8y7ii3wled35p3lw0l2cd7
# account's username 
@username = admin2
# account's password
@password = admin1234
# account's email
@email = admin2@example.com

@apiBaseUrl = {{endpoint}}/api
@eventBaseUrl = {{endpoint}}/event

###
GET {{endpoint}} HTTP/1.1

###
# login
# @name login_cognito
POST {{cognitoEndpoint}}
Accept: application/json
Content-Type: application/x-amz-json-1.1
X-Amz-Target: AWSCognitoIdentityProviderService.InitiateAuth

{
  "AuthFlow": "USER_PASSWORD_AUTH",
  "ClientId": "{{clientId}}",
  "AuthParameters": {
    "USERNAME": "{{username}}",
    "PASSWORD": "{{password}}"
  },
  "ClientMetadata": {}
}

###
@token = {{login_cognito.response.body.AuthenticationResult.IdToken}}
###

# Health
GET {{endpoint}}
Accept: application/json
Authorization: {{token}}

### Create To-do
POST {{apiBaseUrl}}/todo
Accept: application/json
Content-Type: application/json
Authorization: {{token}}
X-Tenant-Code: MBC

{
  "name": "Test Task 1",
  "attributes": {
    "description": "desc",
    "status": "PENDING"
  }
}

login_cognitoCreate To-do を実行すると次のようなメッセージを取得出来ます。

HTTP/1.1 201 Created
x-powered-by: Express
access-control-allow-origin: *
content-type: application/json; charset=utf-8
content-length: 532
etag: W/"214-UJDfqxhJT4p8o3sT996HLlzAbck"
cache-control: no-cache
Date: Sun, 06 Oct 2024 15:32:20 GMT
Connection: close

{
  "pk": "TODO#MBC",
  "sk": "01J9H7BJZ4K711TM2RNM5T7AF0@1",
  "id": "TODO#MBC#01J9H7BJZ4K711TM2RNM5T7AF0",
  "tenantCode": "MBC",
  "code": "01J9H7BJZ4K711TM2RNM5T7AF0",
  "type": "TODO",
  "version": 1,
  "name": "Test Task 1",
  "attributes": {
    "description": "desc",
    "status": "PENDING"
  },
  "requestId": "6bc31a4a-ad47-4487-9d82-fb094a68603f",
  "createdAt": "2024-10-06T15:32:20.069Z",
  "updatedAt": "2024-10-06T15:32:20.069Z",
  "createdBy": "92ca4f68-9ac6-4080-9ae2-2f02a86206a4",
  "updatedBy": "92ca4f68-9ac6-4080-9ae2-2f02a86206a4",
  "createdIp": "127.0.0.1",
  "updatedIp": "127.0.0.1"
}

local-demo-todo-command テーブルを確認すると以下のようにデータが登録されています。

Commandテーブルに追加した内容がDataテーブルに反映されていることを確認します。

Top