Twitterクローンを作ります #11 つぶやきの投稿

前回つぶやきの編集画面の見た目だけ作成したので、実際に投稿できるようにしていきましょう。

# API

まずはAPIを実装していきたいのですが、そのまえにPostクラスを実装しておきましょう。

```py:server/models/post.py
from bson.objectid import ObjectId
from database import db

from datetime import datetime


class Post:
    _collection = db['posts']

    MAX_CONTENT_LENGTH = 140

    def __init__(self,
                content: str,
                posted_by: str,
                created_at: datetime = None,
                updated_at: datetime = None):
        self.content = content
        self.posted_by = posted_by
        if not created_at:
            created_at = datetime.now()
        self.created_at = created_at
        if not updated_at:
            updated_at = datetime.now()
        self.updated_at = updated_at

    def create(self):
        data = vars(self)
        data['posted_by'] = ObjectId(data['posted_by'])
        return self._collection.insert_one(data)
    
    @classmethod
    def validate(cls, body):
        if 'content' not in body or not body['content']:
            return '本文は必須です。'
        content = body['content']
        if len(content) > cls.MAX_CONTENT_LENGTH:
            return f'本文は{cls.MAX_CONTENT_LENGTH}文字以下で入力してください。'
        
        if 'posted_by' not in body or not body['posted_by']:
            return '投稿者は必須です。'
        
        return None

```

よく考えたらユーザーにも作成日時と更新日時を持っておいたほうがよさそうですね。あとで追加しておきましょう。
今回のバリデーションを違反するようなデータは画面からは基本的に送られてこない想定ですがAPI側でもちゃんと弾けるようにしておきましょう。

それではAPIを実装していきます。

```py:server/apis/posts.py
from flask import jsonify, request, session
import pymongo
from models.post import Post
from main import app


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

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

    if session['user']['_id'] != body['posted_by']:
        return jsonify({'error': { 'message': '投稿者が不正です' }}), 400

    last = Post._collection.find_one({'posted_by': body['posted_by']}, sort=[('created_at', pymongo.DESCENDING)])

    if last and last['content'] == body['content']:
        return jsonify({'error': {'message': 'すでに同じ内容のツイートが投稿されています'}}), 400

    post = Post(content=body['content'],
                posted_by=body['posted_by'])

    post.create()

    body['_id'] = str(post._id)
    return jsonify(body)
```

一応本家とあわせて、まったく同じ内容のツイートを連続してできないようにしました。
このAPIではエラーになるのでそこまで問題無いといえばそうなのですが、このままだとログインしていないユーザーがこのAPIを呼び出せてしまいます。
今後追加していくAPIでも、"ログインしていないと利用できない"というものはたくさんあるのでいまのうちに対応しておきましょう。

```py:server/apis/__init__.py
import functools
from flask import session, jsonify

def login_required(status=401):
    def wrapper(f):
        @functools.wraps(f)
        def _wrapper(*args, **kwargs):
            if 'user' not in session:
                return jsonify({'error': { 'message': '権限がありません' }}), status
            return f(*args, **kwargs)
        return _wrapper
    return wrapper
```

これで`@login_required()`を付与しておけばログインしていないユーザーが呼び出した際に401を返してくれるはずです。早速付与しておきましょう。

```py:server/apis/posts.py
from flask import jsonify, request, session
import pymongo
from models.post import Post
from main import app
from apis import login_required


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

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

    if session['user']['_id'] != body['posted_by']:
        return jsonify({'error': { 'message': '投稿者が不正です' }}), 400

    last = Post._collection.find_one({'posted_by': body['posted_by']}, sort=[('created_at', pymongo.DESCENDING)])

    if last and last['content'] == body['content']:
        return jsonify({'error': {'message': 'すでに同じ内容のツイートが投稿されています'}})

    post = Post(content=body['content'],
                posted_by=body['posted_by'])

    post.create()

    body['_id'] = str(post._id)
    return jsonify(body)
```

最後に、main.pyにも忘れずに追加しておきましょう。

```py:server/main.py
from flask import Flask
import logging

app = Flask(__name__)
app.secret_key = 'DUMMY'

app.logger.setLevel(logging.INFO)


from apis import login, users, posts
```

これでAPIはよさそうです。

# 画面側の修正

まずはstoreにつぶやきの投稿処理を追加しましょう。

```javascript:client/src/store/index.js
import { createStore } from 'vuex'
import router from '@/router';

import axios from 'axios';
import UIkit from 'uikit';

export default createStore({

...
  actions: {

...

    createPost ({ state }, content) {
      const body = {
        content,
        posted_by: state.userLoggedIn._id
      };
      return axios.post('/api/posts', body)
        .then(() => {
          return true;
        })
        .catch(error => {
          UIkit.notification(error.response.data.error.message, {status: 'danger'});
          return false;
        });
    }
  },
  modules: {
  }
})

```

うーん、"投稿が成功したら編集画面をクリア、失敗したら編集内容をそのまま残しておく"という挙動を実現したいので、アクションの呼び出し側で成功/失敗を判断できるようにbooleanを返すようにしましたがちょっと微妙な気がしますね...
commitだけはaction内で行って、あとはpromiseを返して呼び出し側でその後の処理を指定できるようにしてあげたほうがいいような気がしてきました。TODOに積んでおきましょう。

とりあえず画面側を修正していきます。

```client/src/components/PostEdit.vue
<template>
    <form @submit="onSubmit">
        <div class="uk-flex uk-flex-top">
            <img src="https://ui-avatars.com/api/?name=あぽろ" class="uk-border-circle avatar">
            <textarea
                v-model="content"
                placeholder="いまどうしてる？" 
                class="uk-textarea uk-margin-left textarea"></textarea>
        </div>
        <div class="uk-flex uk-flex-right">
            <input 
                class="uk-button uk-button-primary uk-margin-right" 
                type="submit" 
                value="ツイートする"
                :disabled="isInvalid">
        </div>
    </form>
</template>

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

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

        const content = ref('');

        const isInvalid = computed(() => {
            const value = content.value;
            if (value.trim().length === 0) {
                return true;
            }

            if (value.length > 140) {
                return true;
            }

            return false;
        });

        const onSubmit = e => {
            e.preventDefault();

            store.dispatch('createPost', content.value)
                .then(success => {
                    if (success) {
                        content.value = '';
                    }
                });
        };
        return {
            content,
            onSubmit,
            isInvalid
        };
    },
}
</script>

<style scoped>
.textarea {
    resize: none;
    border: 0;
    height: 100px;
    margin-top: 5px;
}

.avatar {
    width: 50px;
    height: 50px;
}
</style>
```

![スクリーンショット 2022-03-14 16.13.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/597b625d-8bb6-f6cf-975b-f5914fae9477.png)

つぶやきが投稿できましたね。

今回はここまでですが、再度TODOを確認しておきましょう。
TODOっていままで言ってきましたがどちらかというと技術的な負債に近いですね。

- apiを追加する度にmain.pyに追記が必要になっているのをどうにかしたい
- DB接続情報の管理 (SecretManagerを利用したい)
- CI/CD
- 単体テスト
- actionの戻り値の検討
- git, GitHubの運用方法検討
- lint
- APIドキュメントがない

次回はつぶやきの表示あたりを進めていきましょう。