RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その３、Nuxt.js編

# はじめに

RailsとNuxt3でtodoリストの作り方を
初めから丁寧に説明したいと思います。

使用pcはmacを想定しています。

完成した構成図は以下の通りです。

![aws_structure.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731268/362a1ef8-00e1-75d0-e923-ad2e59eca033.png)

また、githubレポジトリはこちらです。

https://github.com/sugiyama404/ruby_and_nuxt

各シリーズは以下の通りです。

[RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その１、Rails基本設定編](https://qiita.com/sugiyama404/items/637900df3b533fd149f8)
[RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その２、Rails API編](https://qiita.com/sugiyama404/items/acea4cc38fe7f155ab54)
[RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その３、Nuxt.js編](https://qiita.com/sugiyama404/items/4e74c4446add5efc3669)
[RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その４、TerraformECS前編](https://qiita.com/sugiyama404/items/4e19ac1f9be6b8213f55)
[RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その５、TerraformECS後編](https://qiita.com/sugiyama404/items/d0e0e74628d38842c9bd)
[RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その６、Blue/Greenデプロイ前編](https://qiita.com/sugiyama404/items/1ffb4334b3a1546b4e23)
[RailsとNuxt3でtodoリストを作ろう[REST-API/Terraform/Fargate]〜その７、Blue/Greenデプロイ後編](https://qiita.com/sugiyama404/items/ea57e8c08156bf6eca46)

# Nuxt.jsをインストールする

```Dockerfile:webserver/Dockerfile
node:18.15-buster-slim

WORKDIR /usr/src/app
```

コンテナの中に入り、Nuxt.jsをインストールします。

```console
docker compose exec webserver bash
```


myappはアプリケーション名です。
お好きな名前にしましょう。


```console
npx nuxi init myapp
cd myapp
npm install
```


![Screenshot 2023-03-28 at 18.46.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731268/7fb21469-b58d-265e-5e97-9f45f7306ef5.png)


# Dockerの構成を見直す

理由は以下の通りです。

+ railsとport番号が衝突しているので回避する
+ コンテナデプロイができるように、コンテナ起動時にアプリを立ち上げたい
+ Nuxt.jsでホットリロードをしたい
+ ロードバランサーを導入しCORS対策にリバースプロキシをしたい
+ 本番環境用と練習環境用で分けたい

## apserver側(rails)

本番環境では、レッドチームにリバースシェル攻撃を受けた際、パッケージのインストールができないように、ユーザーを変えておきます。

```dockerfile:apserver/Dockerfile
# For development
FROM ruby:3.1.3-slim-buster as development
WORKDIR /usr/src

ENV HOST dbserver
ENV DBNAME todoproject
ENV USERNAME rubyMySql1
ENV PASSWORD rubyMySql1

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
    g++=4:8.3.0-1 make=4.2.1-1.2 \
    libmariadb-dev=1:10.3.38-0+deb10u1 \
    mariadb-client=1:10.3.38-0+deb10u1 \
    git=1:2.20.1-2+deb10u8 \
    && apt-get -y clean \
    && rm -rf /var/lib/apt/lists/*

COPY . .
WORKDIR /usr/src/app
RUN bundle install

# For staging
FROM development as staging

RUN groupadd -r rails && useradd -r -g rails rails
RUN chown -R rails:rails /usr/src/app
USER rails
EXPOSE 8080

CMD ["rails", "s", "-b", "0.0.0.0","-p", "8080"]
```

```text:apserver/.dockerignore
.git
*Dockerfile*
*docker-compose*
```

```bash:apserver/docker-entrypoint
#!/usr/bin/env bash

set -euo pipefail

function tmp_file_exits() {
  if [ -e /usr/src/app/tmp/pids/server.pid ]; then
    rm -f /usr/src/app/tmp/pids/server.pid
    echo "delete successful server.pid file"
  else
    echo "file doesn't exits"
  fi
}

function main() {
  tmp_file_exits
  cd /usr/src/app/app
  bundle exec rails s -p 8080 -b '0.0.0.0'
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  main "$@"
fi
```

## webserver側(Nuxt.js)

WATCHPACK_POLLING=trueはホットリロードを許容しています。


```dockerfile:apserver/Dockerfile
# For development
FROM node:18.15-buster-slim as development

COPY ./docker-entrypoint /docker-entrypoint
WORKDIR /usr/src/app/myapp
ENV WATCHPACK_POLLING=true
ENV NODE_ENV development

COPY ./myapp/package*.json ./
RUN npm install
COPY ./myapp .

ENTRYPOINT [ "/docker-entrypoint" ]

# For build
FROM node:18.15-buster-slim as build
WORKDIR /app
COPY ./myapp/package*.json ./
RUN npm ci
COPY ./myapp .
RUN npm run build

# For staging
FROM gcr.io/distroless/nodejs18-debian11:nonroot as staging
WORKDIR /app
ENV NODE_ENV production
ENV HOST 0.0.0.0

COPY --from=build --chown=nonroot:nonroot /app/.output /app/.output

USER nonroot
EXPOSE 3000

CMD [ "/app/.output/server/index.mjs" ]
```

```text:apserver/.dockerignore
.git
*Dockerfile*
*docker-compose*
node_modules
```

/tmp/nitro/worker-30-1.sockが存在するため、エラーが出て
立ち上がらないので、コンテナ起動時に削除しています。

```bash:webserver/docker-entrypoint
#!/usr/bin/env bash

set -euo pipefail

function tmp_file_exits() {
  if [ "$(ls -A /tmp/nitro/)" ]; then
    rm /tmp/nitro/*
    echo "delete successful tmp file"
  else
    echo "file doesn't exits"
  fi
}

function main() {
  tmp_file_exits
  npm run dev
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  main "$@"
fi
```

## loadbalancer側
```nginx:loadbalancer/nginx.conf
server {
    listen 80;
    listen [::]:80;
    server_name localhost;

    location / {
        proxy_pass http://webserver:3000;
    }
    location /api/ {
        proxy_pass http://apserver:8080;
    }
}
```

## docker-compose

24678はNuxt.jsのホットリロード時の待ち受けportです。


```yaml:docker-compose.yml
version: "3"
services:
  apserver:
    build:
      context: ./apserver
      target: development
    container_name: "ruby"
    tty: true
    entrypoint: /usr/src/docker-entrypoint
    volumes:
      - ./apserver:/usr/src
    ports:
      - "8080:8080"
  webserver:
    build:
      context: ./webserver
      target: development
    volumes:
      - ./webserver/myapp:/usr/src/app/myapp
      - /usr/src/app/myapp/node_modules
    ports:
      - "3000:3000"
      - '24678:24678'
  loadbalancer:
    image: nginx:1.22-bullseye
    ports:
      - "80:80"
    volumes:
      - ./loadbalancer:/etc/nginx/conf.d:ro
  dbserver:
    build: ./dbserver
    container_name: mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: todoproject
      MYSQL_USER: rubyMySql1
      MYSQL_PASSWORD: rubyMySql1
      TZ: 'Asia/Tokyo'
    command: mysqld
    volumes:
      - ./dbserver/conf/my.cnf:/etc/mysql/conf.d/my.cnf
    ports:
      - 3306:3306
    cap_add:
      - SYS_NICE
```

# Nuxt.jsを作ろう

## railsを少しいじる

出力をselectする。

```ruby:apserver/app/app/controllers/api/todos_controller.rb
def index
    @todos = Todo.all.select(:id, :content)
    render status: :ok, json: @todos #200
```

## 共通部分を作る

外部apiからデータを受け取りたいので、ssr: falseする。
また、cssフレームワークPrimer CSSをインポートします。

```typescript:webserver/myapp/nuxt.config.ts
export default defineNuxtConfig({
    ssr: false,
    app: {
        head: {
            title: "Todolist",
            meta: [
                { charset: "utf-8" },
                { name: "viewport", content: "width=device-width, initial-scale=1" },
            ],
            link: [
                { rel: "icon", type: "image/x-icon", href: "/favicon.ico" },
                { rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/@primer/css@20.8.3/dist/primer.min.css" }
            ],
            script: [
                { src: "https://cdn.jsdelivr.net/npm/@primer/css@20.8.3/dist/primer.min.js" }
            ],
        },
    }

})
```

全体のtemplate設定

```typescript:webserver/myapp/app.vue
<template>
  <div>
    <NuxtLayout>
      <NuxtPage />
    </NuxtLayout>
  </div>
</template>
```

header、hooterなど共通レイアウトを設定できます。


```typescript:webserver/myapp/layouts/default.vue
<template>
    <div>
        <Header />
        <slot />
    </div>
</template>
```

```typescript:webserver/myapp/pages/index.vue
<script>
import Tables from '~~/components/Tables.vue';
import Forms from '~~/components/Forms.vue';
</script>
<template>
    <div class="d-flex flex-justify-center flex-items-center">
        <div class="container mt-3">
            <div class="columns mb-1">
                <Forms />
            </div>
            <br>
            <div class="columns mb-1">
                <Tables />
            </div>
        </div>
    </div>
</template>

<style>
p {
    text-align: center;
}
</style>
```

```typescript:webserver/myapp/composables/useFlagStore.ts
import { Ref } from "nuxt/dist/app/compat/capi";

export const useFlagStore = () => {
    const flag = useState("flag", () => (false))
    return {
        flag: readonly(flag),
        setFlag: setFlag(flag),
        deleteTodo: deleteTodo(flag)
    };
};

const setFlag = (flag: Ref<boolean>) => (val: boolean) => { flag.value = val };

const deleteTodo = (flag: Ref<boolean>) => (num: string) => {
    const currentUrl = window.location.href
    const pending = useFetch(currentUrl + 'api/todos/' + num, { method: "delete", })
    flag.value = true
};
```
## comportnentを作る

```typescript:webserver/myapp/components/Forms.vue
<script setup lang="ts">

const { setFlag } = useFlagStore();
const task = ref('');

const handleClick = () => {
    const currentUrl = window.location.href;
    useFetch(currentUrl + 'api/todos', {
        method: 'post',
        body: { "content": task.value },
    });
    task.value = '';
    setFlag(true);
};
</script>

<template>
    <div>
        <form>
            <div class="input-group">
                <input class="form-control" type="text" placeholder="write your something to do" v-model="task">
                <span class="input-group-button">
                    <button class="btn" type="button" aria-label="Copy to clipboard" @click="handleClick">
                        confirm
                    </button>
                </span>
            </div>
        </form>
    </div>
</template>
```

```typescript:webserver/myapp/components/Header.vue
<template>
    <h1 class="header">todolist</h1>
</template>
<style>
.header {
    z-index: 999;
    top: 0;
    left: 0;
    width: 100%;
    padding: 20px 40px;
    background: #eee;
    box-sizing: border-box;
}
</style>
```

```typescript:webserver/myapp/components/Tables.vue
<script setup lang="ts">
const currentUrl = window.location.href;
const { data, pending, error, refresh } = await useFetch(currentUrl + 'api/todos', { method: "get", });

const { flag, setFlag, deleteTodo } = useFlagStore();
watch(flag, () => {
    if (flag) {
        const startMsec = new Date();
        while (new Date() - startMsec < 100);
        refresh();
        setFlag(false);
    }
})
</script>

<template>
    <div>
        <p v-if="error">{{ error }}</p>
        <table border="1" width="500">
            <tbody>
                <tr v-for="todo in data" :key="todo.id">
                    <td class="content">{{ todo.content }}</td>
                    <td class="button">
                        <button class="btn-mktg btn-signup-mktg full-button" type="button"
                            @click="deleteTodo(todo.id)">Delete</button>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
</template>

<style>
.full-button {
    width: 100%;
    height: 100%;
}

.content {
    width: 80%;
}

.button {
    width: 20%;
}
</style>
```