Twitterクローンを作ります #7 細かい修正

前回上げた下記の点を修正していこうと思います。

- ユーザー名のバリデーション(文字数, 利用可能文字)
- ユーザー名 or メールアドレスでのログイン
- 画面のレイアウト (変なメニューが表示されている)

# ユーザー名のバリデーション

本家は下記ページを参照すると、以下の仕様のようです。

- ユーザー名は15文字以下
- ユーザー名に利用可能な文字は英数字とアンダースコアのみ
- 表示名は4文字以上50文字以下

https://help.twitter.com/ja/managing-your-account/twitter-username-rules

(ユーザー名の最小文字数は1文字のようです)

これに、パスワードの文字数を6文字以上100文字以下としてバリデーション処理を実装していきましょう。


```py:server/models/user.py
from server.database import db

import hashlib
import re

class User:
    _collection = db['users']

    MIN_USERNAME_LENGTH = 1
    MAX_USERNAME_LENGTH = 15
    USERNAME_PATTERN = '^[a-zA-Z0-9_]+$'
    MIN_DISPLAY_NAME_LENGTH = 4
    MAX_DISPLAY_NAME_LENGTH = 50
    MIN_PASSWORD_LENGTH = 6
    MAX_PASSWORD_LENGTH = 100
    EMAIL_PATTERN = '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$'

    def __init__(self,
                display_name: str,
                username: str,
                password: str,
                email: str):
        self.display_name = display_name
        self.username = username
        self.hashed_password = self.encrypt(password)
        self.email = email
    
    @staticmethod
    def encrypt(password: str):
        return hashlib.sha256(password.encode()).hexdigest()

    def create(self):
        return self._collection.insert_one(vars(self))
    
    @classmethod
    def validate(cls, body):
        if 'username' not in body:
            return 'ユーザー名は必須です。'
        username = body['username']
        if not re.match(cls.USERNAME_PATTERN, username):
            return 'ユーザー名には英数字(a~z A~z 0~9)とアンダースコア(_)以外利用できません。'
        if len(username) < cls.MIN_USERNAME_LENGTH:
            return f'ユーザー名は{cls.MIN_USERNAME_LENGTH}文字以上で指定してください。'
        if len(username) > cls.MAX_USERNAME_LENGTH:
            return f'ユーザー名は{cls.MAX_USERNAME_LENGTH}文字以下で指定してください。'
        
        if 'display_name' not in body:
            return '表示名は必須です。'
        display_name = body['display_name']
        if len(display_name) < cls.MIN_DISPLAY_NAME_LENGTH:
            return f'表示名は{cls.MIN_DISPLAY_NAME_LENGTH}文字以上で指定してください。'
        if len(display_name) > cls.MAX_DISPLAY_NAME_LENGTH:
            return f'表示名は{cls.MAX_DISPLAY_NAME_LENGTH}文字以下で指定してください。'
        
        if 'password' not in body:
            return 'パスワードは必須です。'
        password = body['password']
        if len(password) < cls.MIN_PASSWORD_LENGTH:
            return f'パスワードは{cls.MIN_PASSWORD_LENGTH}文字以上で指定してください。'
        if len(password) > cls.MAX_PASSWORD_LENGTH:
            return f'パスワードは{cls.MAX_PASSWORD_LENGTH}文字以下で指定してください。'

        if 'email' not in body:
            return 'メールアドレスは必須です。'
        email = body['email']
        if not re.match(cls.EMAIL_PATTERN, email):
            return 'メールアドレスが正しくありません。'

        return None

```

バリデーション処理の呼び出しは以下です。

```py:server/apis/users.py
...

@app.route('/api/users', methods=['POST'])
def register():
    body = request.json

    error_message = User.validate(body)
    if error_message:
        return jsonify({'error': { 'message': error_message }}), 400

    if User._collection.find_one({'username': body['username']}):
        return jsonify({'error': { 'message': '登録済みのユーザー名です。' }}), 400
    if User._collection.find_one({'email': body['email']}):
        return jsonify({'error': { 'message': '登録済みのメールアドレスです。'}}), 400

    user = User(display_name=body['display_name'],
                username=body['username'],
                password=body['password'],
                email=body['email'])
    user.create()
    del body['password']
    user = vars(user)
    body['_id'] = user['_id'] = str(user['_id'])
    session['user'] = user
    return jsonify(body)
```

# ユーザー名 or メールアドレスでのログイン

ユーザー名に`@`が使えなくなったことで、ログイン時に指定するIDはユーザー名でもメールアドレスでも良くなったためどちらでもログインできるようにしていきましょう。

API側の修正です。

```py:server/apis/login.py
...

@app.route('/api/login', methods=['POST'])
def login():
    body = request.json
    if 'identifier' not in body:
        return jsonify({'error': { 'message': 'ユーザー名 または メールアドレスは必須です。' }}), 400
    if 'password' not in body:
        return jsonify({'error': { 'message': 'パスワードは必須です。' }}), 400

    identifier = body['identifier']
    user = User._collection.find_one({'$or': [{'email': identifier}, {'username': identifier}]})

    if not user:
        return jsonify({'error': { 'message': 'ユーザーが存在しません。' }}), 400
    
    hashed_password = User.encrypt(body['password'])
    if user['hashed_password'] != hashed_password:
        return jsonify({'error': { 'message': 'パスワードが一致しません。' }}), 400
    
    
    del user['hashed_password']
    
    user['_id'] = str(user['_id'])
    session['user'] = user
    return jsonify(user)

```

ユーザー名が渡されるかもしれないのにemailという名前だと不適切かなと思ったので、identifierに変えました。

画面側も合わせて修正しましょう。

```vue:client/src/views/LoginView.vue
<template>
    <div class="uk-container uk-width-1-2">
        <h1>Toitta Login</h1>

        <form class="uk-form-horizontal uk-margin-large" @submit.prevent>
            <div class="uk-margin">
                <label class="uk-form-label" for="login__identifier">ユーザー名 または メールアドレス</label>
                <div class="uk-form-controls">
                    <input
                        class="uk-input"
                        id="login__identifier"
                        type="text"
                        v-model="identifier"
                        required>
                </div>
            </div>
            <div class="uk-margin">
                <label class="uk-form-label" for="login__password">パスワード</label>
                <div class="uk-form-controls">
                    <input 
                        class="uk-input"
                        id="login__password"
                        type="password"
                        v-model="password"
                        required>
                </div>
            </div> 
            <div class="uk-margin">
                <input class="uk-button uk-button-primary" type="submit"  value="ログイン" @click="onClick">
            </div>      
        </form>
    </div>
</template>

<script>
import { ref } from 'vue';
import { useStore } from 'vuex';

export default {
    setup() {
        const store = useStore();

        const identifier = ref("");
        const password = ref("");
        const onClick = () => {
            const credential = {
                username: identifier.value,
                password: password.value
            };

            store.dispatch('login', credential);
        };
        return {
            identifier,
            password,
            onClick
        };
    }
}
</script>
```

# 画面のレイアウト

vue-cli で生成されたファイルをそのままにしていたために、全ページの上部にnavが表示されてしまっていたのでなおしましょう。
ついでに、ログイン画面とユーザー登録画面以外ではサイドメニューを表示したいので、これを実現できるような仕組みも実装していきます(https://qiita.com/asauasa/items/a8f1a7df6d017712ea91 を参考にしました)。

まずはログイン画面とユーザー登録画面用のレイアウトを定義していきます。

```vue:client/src/components/layouts/SimpleLayout.vue
<template>
    <div>
        <router-view></router-view>
    </div>
</template>
```

メイン画面用のレイアウトを定義します。最終的にはサイドメニューにしますが、レイアウトの切り替えを確認したいのでとりあえず今までのメニューを表示するようにしておきましょう。

```vue:client/src/components/layouts/DefaultLayout.vue
<template>
    <div>
        <nav>
            <router-link to="/">Home</router-link> |
            <router-link to="/about">About</router-link>
        </nav>
        <router-view></router-view>
    </div>
</template>
```

どのページでどのレイアウトを利用するかを、router/index.jsで指定してあげます。

```js:client/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/about',
    name: 'about',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
  },
  {
    path: '/register',
    name: 'register',
    meta: { layout: 'simple-layout' },
    component: () => import('../views/RegisterView.vue')
  },
  {
    path: '/login',
    name: 'login',
    meta: { layout: 'simple-layout' },
    component: () => import('../views/LoginView.vue')
  }
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router
```

App.vue で router の設定に応じてレイアウトを切り替える処理を追加します。

```vue:client/src/App.vue
<template>
  <div>
    <component v-bind:is="layout()"></component>
  </div>
</template>

<script>
import { useRoute } from 'vue-router';
import DefaultLayout from '@/components/layouts/DefaultLayout.vue';
import SimpleLayout from '@/components/layouts/SimpleLayout.vue';

export default {
  components: {
    DefaultLayout,
    SimpleLayout
  },
  setup() {
    const route = useRoute();
    const layout = () => {
      return route.meta.layout ? route.meta.layout : 'default-layout';
    };
    return {
      layout
    }
  },
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}
</style>
```

これで、レイアウトを切り替えることができました。

![スクリーンショット 2022-03-13 0.06.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/0d555f0d-cb14-0956-6159-d20430b505ba.png)

![スクリーンショット 2022-03-13 0.06.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/41ab0ffb-a4da-42c2-bcb6-70a5214e745f.png)

今回はここまでにします。TODOは以下になりました。

- apiを追加する度に__init__.pyに追記が必要になっているのをどうにかしたい
- DB接続情報の管理 (SecretManagerを利用したい)
- 実行環境をクラウドに移す
- CI/CD
- 単体テスト

次回はメイン画面を作成していきます。