【SST ion】次世代IaCでユーザ認証つきTodoアプリを作ってみた

# SST ionとは
SSTは、自分のインフラ上で最新のフルスタックアプリケーションを簡単に構築できるフレームワークです。

v2までCDKベースでしたが、v3からPulumiとTerraformを使用したEngine（ion）になります。

:::note warn
ionはGAされていますが、日々アップデートされています。
:::

なぜ、SSTの内部エンジンをCDKからpulumiに移行したかは、下記リンクの記事に解説があります。  
https://sst.dev/blog/moving-away-from-cdk.html

PulumiやTerraformは、各クラウドプロバイダーのAPIに直接アクセスし、状態管理やリソースの作成・更新を行います。この直接的なアプローチは、AWS CDKのようにCloudFormationを介する必要がないため、リソースのプロビジョニングが速くなる可能性があるそうです。

なので、AWS CDKユーザには、一度使ってみていただきたいです。

## 筆者について
私は外資のセンサ会社で、組み込みシステムの開発を行っています。
1年半前に本社で3ヶ月ほどIoTプラットフォームの開発をしていた際に、このSSTに出会いましたが、当時はまだCDKベースだったため、CDKの実行が遅く、チームメンバのドイツ人はコーヒータイムだとかなんとかいいながら、のんびりやっていました。

今回、ionを触ってみて開発体験がよく、楽しかったので共有してみます。

間違いやアドバイスあれば、コメントいただければと思います。

# 今回のアプリのアーキテクチャ

SSTは、Nextはもちろん、RemixやAstro、Svelte等のモダンなWebフレームワークに対応しており、プラットフォームは、現在AWSとCloudFlareに対応しているようです。

今回は、AWSでReactとLambdaのモノレポ構成とという選択にしました。

![ion-todo_software-architecture-detail.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271834/6a290db5-279e-23ed-ebed-69d003722e17.png)

# リポジトリ
インフラ部分、フロントエンド、LambdaすべてTypeScriptで書いています。

https://github.com/gusugusu1018/sst-ion-serverless-todoapp

コメント、スター、issueなどいただければ嬉しいです。

# Github Actionsを使ったproductionデプロイ
SSTでは通常localユーザのステージで開発し、下記のように、stageオプションをつけると、productionステージにデプロイできます。

```
--stage production
```

今回、Github Actionsでmainブランチのpushで、デプロイできるようにしてみました。

ポイントは、実行時間を短縮するために、pnpmはもちろんSSTをキャッシュできるようにしたところです。

ちなみに、AWSのOIDC認証と、GoogleのSocial SignInにむけて、Secretを使っています。

```yaml
env:
  AWS_REGION: ap-northeast-1
  AWS_OIDC_ROLE_ARN: ${{ secrets.AWS_OIDC_ROLE_ARN }}
  PNPM_STORE_PATH:

permissions:
  id-token: write
  contents: read

on:
  push:
    paths-ignore:
      - '**.md'
      - 'docs/**'
      - '.vscode/**'
      - '.prettierignore'
    branches:
      - main

jobs:
  deploy:
    name: deploy
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Assume Role
        uses: aws-actions/configure-aws-credentials@v1
        with:
          role-to-assume: ${{ env.AWS_OIDC_ROLE_ARN }}
          aws-region: ${{env.AWS_REGION}}

      - name: Setup sst ion cache
        uses: actions/cache@v4
        id: ion-cache
        with:
          path: ~/.sst
          key: ${{ runner.os }}-sst-ion-${{ hashFiles('**/sst-version-lock.txt') }}
          restore-keys: |
            ${{ runner.os }}-sst-ion-

      - name: Setup sst from cache
        if: steps.ion-cache.outputs.cache-hit == 'true'
        run: 'echo "~/.sst/bin" >> $GITHUB_PATH'

      - name: Install SST ion if not from cache
        if: steps.ion-cache.outputs.cache-hit != 'true'
        run: bash ./install_sst.sh

      - name: Disable SST telemetry
        run: sst telemetry disable

      - name: Set secret for sst
        env:
          GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
          GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
        run: |
          sst secret set GoogleClientId $GOOGLE_CLIENT_ID --stage uat
          sst secret set GoogleClientSecret $GOOGLE_CLIENT_SECRET --stage uat

      - name: Install Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 8
          run_install: false

      - name: Get pnpm store directory
        shell: bash
        run: |
          echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV

      - name: Setup pnpm cache
        uses: actions/cache@v4
        id: pnpm-cache
        with:
          path: ${{ env.PNPM_STORE_PATH }}
          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-store-

      - name: Check pnpm cache hit
        run: 'echo "Cache hit: ${{ steps.pnpm-cache.outputs.cache-hit }}"'

      - name: Install dependencies
        if: steps.pnpm-cache.outputs.cache-hit != 'true'
        run: pnpm install

      - name: Deploy
        run: sst deploy --stage uat
```

このように、だいぶややこしくなってしまったので、テスト段階ではactを使って、local検証してました。

https://github.com/nektos/act

## SST version lock
SSTの本体リポジトリのshell scriptをつかってしまうと、latestが自動的に入ってしまうため、version lockができる機構を作ってみました。

sstのversionをファイルに落とし込み、このファイル内の値をもとにキャッシュヒットできるようにしています。

```
sst version >> sst-version-lock.txt
```

sstのinstallスクリプトをすこし修正して、sst-version-lock.txt内のバージョンをInstallできるようにしました。

https://github.com/gusugusu1018/sst-ion-serverless-todoapp/blob/main/install_sst.sh

## Productionデプロイ時の構成を変化する機構（ドメインやCORS）

まだ開発途中なので、uatステージにしていますが、productionステージで、Route53のdomainを設定できるようにしています。

```typescript
import { api } from './api';
import { authUrl, userPool, userPoolClient } from './auth';
import { domain } from './domain';

const commonConfig = {
  path: 'packages/frontend',
  build: {
    output: 'dist',
    command: 'pnpm run build',
  },
  environment: {
    VITE_API_URL: api.url,
    VITE_REGION: aws.getRegionOutput().name,
    VITE_USER_POOL_ID: userPool.id,
    VITE_USER_POOL_CLIENT_ID: userPoolClient.id,
    VITE_USER_POOL_DOMAIN: authUrl,
  },
};

// TODO: change stage to 'production' when ready
const webConfig =
  $app.stage === 'uat' ? { ...commonConfig, domain } : commonConfig;

export const web = new sst.aws.StaticSite('StaticSite', webConfig);
```

開発時には、localのフロントエンドでAPIのテストをするため、CORSをゆるくして、
productionでは、ドメインを設定できるように、変更してます。

```typescript
import { userPool, userPoolClient } from './auth';
import { table } from './database';
import { domain } from './domain';

const corsCommonConfig = {
  allowHeaders: ['Content-Type', 'Authorization'],
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
};

// TODO: change stage to 'production' when ready
const corsConfiguration =
  $app.stage === 'uat'
    ? { ...corsCommonConfig, allowOrigins: [$interpolate`https://${domain}`] }
    : { ...corsCommonConfig, allowOrigins: ['*'] };

export const api = new sst.aws.ApiGatewayV2('Api', {
  transform: {
    api: {
      corsConfiguration,
    },
    route: {
      args: (props) => {
        props.auth = {
          jwt: {
            audiences: [userPoolClient.id],
            issuer: $interpolate`https://cognito-idp.${aws.getRegionOutput().name}.amazonaws.com/${userPool.id}`,
          },
        };
      },
    },
  },
});

api.route('GET /todos', {
  link: [table],
  handler: 'packages/backend/src/taskService/list.main',
});

api.route('POST /todos', {
  link: [table],
  handler: 'packages/backend/src/taskService/create.main',
});

api.route('PUT /todos/{id}', {
  link: [table],
  handler: 'packages/backend/src/taskService/update.main',
});

api.route('DELETE /todos/{id}', {
  link: [table],
  handler: 'packages/backend/src/taskService/delete.main',
});
```

# Lambda functionまわり

SSTはLambda functionのLive Developができることが特徴の一つです。
TypescriptでLambdaを書き換えたら、hot reloadで、クラウドのfunctionが書き換わり、デバックができます。

今回、Lambdaのミドルウェアであるmiddyを使ってみました。
ValidationはZodを使ってみたかったので、zod用のmiddyのmiddlewareを作って、使っています。

```zod-validator.ts
import middy from '@middy/core';
import { MiddlewareObj } from '@middy/core';
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { ZodError, ZodSchema } from 'zod';

class ZodValidationError extends Error {
  public statusCode: number;
  public details: ZodError;

  constructor(message: string, details: ZodError) {
    super(message);
    this.name = 'ValidationError';
    this.statusCode = 400; // 400 Bad Request
    this.details = details;
  }
}

const zodValidationMiddleware = <T>(
  schema: ZodSchema<T>,
): MiddlewareObj<APIGatewayProxyEvent, APIGatewayProxyResult> => {
  const before: middy.MiddlewareFn<
    APIGatewayProxyEvent,
    APIGatewayProxyResult
  > = async (request) => {
    try {
      const body = request.event.body;
      schema.parse(body);
    } catch (error: unknown) {
      if (error instanceof ZodError) {
        throw new ZodValidationError('Invalid request', error);
      } else {
        throw error;
      }
    }
  };

  return {
    before,
  };
};

export default zodValidationMiddleware;
```

DynamoDBはSingle Table Architecureで使ってみたいと思っていたので、ElectroDBを使いました。
今回は、TaskとUserの2つのEntityを作ってます。
こんな感じで、DynamoDBの操作ができます。

```update.ts
import middy from '@middy/core';
import httpErrorHandler from '@middy/http-error-handler';
import jsonBodyParser from '@middy/http-json-body-parser';
import {
  UpdateTodoRequest,
  UpdateTodoRequestSchema,
} from '@sst-ion-serverless-todoapp/types';
import {
  APIGatewayProxyEventV2,
  APIGatewayProxyEventV2WithJWTAuthorizer,
  APIGatewayProxyResult,
} from 'aws-lambda';

import { TaskEntity } from '../entities/task';
import zodValidationMiddleware from '../lib/middleware/zod-validator';
import HttpStatusCode from '../lib/utils/HttpStatusCode';

const lambdaHandler = async (
  event: APIGatewayProxyEventV2WithJWTAuthorizer & { body: UpdateTodoRequest },
): Promise<APIGatewayProxyResult> => {
  if (!event?.pathParameters?.id) {
    throw new Error('Missing taskId in path parameters');
  }

  const claims = event.requestContext.authorizer.jwt.claims;

  const result = await TaskEntity.update({
    userId: claims.sub.toString(),
    taskId: event?.pathParameters?.id,
  })
    .set({ completed: event.body.completed })
    .go({ response: 'all_new' });

  return {
    statusCode: HttpStatusCode.OK,
    headers: { contentType: 'application/json' },
    body: JSON.stringify(result.data),
  };
};

export const main = middy<APIGatewayProxyEventV2, APIGatewayProxyResult>()
  .use(jsonBodyParser())
  .use(zodValidationMiddleware(UpdateTodoRequestSchema))
  .use(httpErrorHandler())
  .handler(lambdaHandler);

```


単純なREST APIだけだと面白くないので、CognitoのTriggerでUserItemを操作する機構もいれてみました。

```postAuthentication.ts
import { Handler, PostAuthenticationTriggerEvent } from 'aws-lambda';

import { UserEntity } from '../entities/user';

export const main: Handler<PostAuthenticationTriggerEvent> = async (
  event: PostAuthenticationTriggerEvent,
) => {
  console.log(event);
  const { sub } = event.request.userAttributes;
  await UserEntity.update({ userId: sub })
    .set({ lastSignedInAt: new Date().toISOString() })
    .go();

  return event;
};
```

# おわり
SST ion使っていて楽しいので、ぜひ使ってみてください。